Skip to content

Commit 7d5de62

Browse files
committed
feat: Better Auth 기반 인증 기능 강화
- 회원정보 수정(프로필/비밀번호) 페이지 추가 (/settings/profile, /settings/password) - 소셜 로그인 지원 (GitHub, Google, Naver, Kakao) 및 OAuth 리다이렉트 처리 - auth.ts에 role/status 추가, updateUser 설정 및 socialProviders 적용 - 인증 스키마(password, profile) 및 가이드라인 문서 추가
1 parent 8b87b1f commit 7d5de62

13 files changed

Lines changed: 450 additions & 2 deletions

File tree

web/src/app/auth/complete/page.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { authClient } from "@/lib/auth-client";
6+
import { ROLE_REDIRECT_MAP } from "@/constants/auth";
7+
import Loader from "@/components/common/Loader";
8+
9+
/**
10+
* 소셜 로그인 콜백 후 리다이렉트 처리.
11+
* Better Auth OAuth callback 후 이 페이지로 이동하며, 역할에 맞는 경로로 리다이렉트합니다.
12+
*/
13+
export default function AuthCompletePage() {
14+
const router = useRouter();
15+
16+
useEffect(() => {
17+
const redirect = async () => {
18+
const { data } = await authClient.getSession();
19+
const role = data?.user ? (data.user as { role?: string }).role : undefined;
20+
const path = role ? ROLE_REDIRECT_MAP[role] || "/" : "/";
21+
router.replace(path);
22+
};
23+
24+
redirect();
25+
}, [router]);
26+
27+
return (
28+
<div className="flex min-h-[50vh] items-center justify-center">
29+
<Loader />
30+
</div>
31+
);
32+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname } from "next/navigation";
5+
import { cn } from "@/lib/utils";
6+
7+
const NAV_ITEMS = [
8+
{ href: "/settings/profile", label: "프로필" },
9+
{ href: "/settings/password", label: "비밀번호" },
10+
] as const;
11+
12+
export default function SettingsNav() {
13+
const pathname = usePathname();
14+
15+
return (
16+
<nav className="flex gap-4 border-b border-border mb-8">
17+
{NAV_ITEMS.map(({ href, label }) => (
18+
<Link
19+
key={href}
20+
href={href}
21+
className={cn(
22+
"pb-3 px-1 text-sm font-medium border-b-2 border-transparent hover:text-foreground transition-colors",
23+
pathname === href
24+
? "border-primary text-foreground"
25+
: "text-muted-foreground"
26+
)}
27+
>
28+
{label}
29+
</Link>
30+
))}
31+
</nav>
32+
);
33+
}

web/src/app/settings/layout.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { redirect } from "next/navigation";
2+
import Link from "next/link";
3+
import { auth } from "@/lib/auth";
4+
import { headers } from "next/headers";
5+
import SettingsNav from "./SettingsNav";
6+
7+
export default async function SettingsLayout({
8+
children,
9+
}: {
10+
children: React.ReactNode;
11+
}) {
12+
const session = await auth.api.getSession({
13+
headers: await headers(),
14+
});
15+
16+
if (!session) {
17+
redirect("/");
18+
}
19+
20+
return (
21+
<div className="min-h-screen">
22+
<header className="sticky top-0 z-40 w-full border-b bg-background/80 backdrop-blur-sm">
23+
<div className="container mx-auto flex h-16 items-center px-4">
24+
<Link
25+
href="/"
26+
className="text-sm text-muted-foreground hover:text-foreground"
27+
>
28+
← 홈으로
29+
</Link>
30+
<span className="ml-4 font-semibold">계정 설정</span>
31+
</div>
32+
</header>
33+
<div className="container mx-auto max-w-2xl py-8 px-4">
34+
<SettingsNav />
35+
{children}
36+
</div>
37+
</div>
38+
);
39+
}

web/src/app/settings/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from "next/navigation";
2+
3+
export default function SettingsPage() {
4+
redirect("/settings/profile");
5+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import { useForm } from "react-hook-form";
4+
import { zodResolver } from "@hookform/resolvers/zod";
5+
import { authClient } from "@/lib/auth-client";
6+
import { useToastStore } from "@/stores/useToastStore";
7+
import {
8+
ChangePasswordRequestSchema,
9+
type ChangePasswordRequest,
10+
} from "@/schemas/auth/password";
11+
import InputField from "@/components/common/InputField";
12+
import { Button } from "@/components/ui/button";
13+
import { cn } from "@/lib/utils";
14+
15+
export default function PasswordPage() {
16+
const { showToast } = useToastStore();
17+
18+
const {
19+
register,
20+
handleSubmit,
21+
reset,
22+
formState: { errors, isSubmitting },
23+
} = useForm<ChangePasswordRequest>({
24+
resolver: zodResolver(ChangePasswordRequestSchema),
25+
defaultValues: {
26+
currentPassword: "",
27+
newPassword: "",
28+
confirmPassword: "",
29+
},
30+
});
31+
32+
const onSubmit = async (payload: ChangePasswordRequest) => {
33+
const { data, error } = await authClient.changePassword({
34+
currentPassword: payload.currentPassword,
35+
newPassword: payload.newPassword,
36+
revokeOtherSessions: false,
37+
});
38+
39+
if (error) {
40+
showToast(error.message || "비밀번호 변경에 실패했습니다.", "error");
41+
return;
42+
}
43+
44+
if (data) {
45+
showToast("비밀번호가 변경되었습니다.", "success");
46+
reset();
47+
}
48+
};
49+
50+
return (
51+
<form
52+
onSubmit={handleSubmit(onSubmit)}
53+
className="flex flex-col gap-6 max-w-md"
54+
>
55+
<InputField
56+
id="currentPassword"
57+
label="현재 비밀번호"
58+
type="password"
59+
register={register}
60+
errors={errors}
61+
autoComplete="current-password"
62+
/>
63+
<InputField
64+
id="newPassword"
65+
label="새 비밀번호"
66+
type="password"
67+
register={register}
68+
errors={errors}
69+
autoComplete="new-password"
70+
/>
71+
<InputField
72+
id="confirmPassword"
73+
label="새 비밀번호 확인"
74+
type="password"
75+
register={register}
76+
errors={errors}
77+
autoComplete="new-password"
78+
/>
79+
<p className="text-sm text-muted-foreground">
80+
비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다.
81+
</p>
82+
<Button
83+
type="submit"
84+
disabled={isSubmitting}
85+
className={cn(
86+
"w-fit rounded-xl px-6 py-2 font-bold shadow-lg shadow-primary/20",
87+
"disabled:opacity-50"
88+
)}
89+
>
90+
{isSubmitting && (
91+
<span
92+
className="mr-2 inline-block size-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
93+
aria-hidden
94+
/>
95+
)}
96+
비밀번호 변경
97+
</Button>
98+
</form>
99+
);
100+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { useForm } from "react-hook-form";
5+
import { zodResolver } from "@hookform/resolvers/zod";
6+
import { authClient } from "@/lib/auth-client";
7+
import { useToastStore } from "@/stores/useToastStore";
8+
import {
9+
ProfileUpdateRequestSchema,
10+
type ProfileUpdateRequest,
11+
} from "@/schemas/auth/profile";
12+
import { Button } from "@/components/ui/button";
13+
import InputField from "@/components/common/InputField";
14+
import { cn } from "@/lib/utils";
15+
16+
export default function ProfilePage() {
17+
const [isLoading, setIsLoading] = useState(true);
18+
const { showToast } = useToastStore();
19+
20+
const {
21+
register,
22+
handleSubmit,
23+
reset,
24+
formState: { errors, isSubmitting },
25+
} = useForm<ProfileUpdateRequest>({
26+
resolver: zodResolver(ProfileUpdateRequestSchema),
27+
defaultValues: { name: "", image: "" },
28+
});
29+
30+
useEffect(() => {
31+
const load = async () => {
32+
const { data } = await authClient.getSession();
33+
if (data?.user) {
34+
const u = data.user as { name?: string; image?: string | null };
35+
reset({
36+
name: u.name || "",
37+
image: u.image || "",
38+
});
39+
}
40+
setIsLoading(false);
41+
};
42+
load();
43+
}, [reset]);
44+
45+
const onSubmit = async (payload: ProfileUpdateRequest) => {
46+
const { data, error } = await authClient.updateUser({
47+
name: payload.name.trim(),
48+
image: payload.image?.trim() || undefined,
49+
});
50+
51+
if (error) {
52+
showToast(error.message || "수정에 실패했습니다.", "error");
53+
return;
54+
}
55+
56+
if (data) {
57+
showToast("프로필이 수정되었습니다.", "success");
58+
}
59+
};
60+
61+
if (isLoading) {
62+
return (
63+
<div className="flex min-h-[200px] items-center justify-center">
64+
<span className="inline-block size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
65+
</div>
66+
);
67+
}
68+
69+
return (
70+
<form
71+
onSubmit={handleSubmit(onSubmit)}
72+
className="flex flex-col gap-6 max-w-md"
73+
>
74+
<InputField
75+
id="name"
76+
label="이름"
77+
register={register}
78+
errors={errors}
79+
autoComplete="name"
80+
/>
81+
<InputField
82+
id="image"
83+
label="프로필 이미지 URL"
84+
register={register}
85+
errors={errors}
86+
placeholder="https://..."
87+
/>
88+
<Button
89+
type="submit"
90+
disabled={isSubmitting}
91+
className={cn(
92+
"w-fit rounded-xl px-6 py-2 font-bold shadow-lg shadow-primary/20",
93+
"disabled:opacity-50"
94+
)}
95+
>
96+
{isSubmitting && (
97+
<span
98+
className="mr-2 inline-block size-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
99+
aria-hidden
100+
/>
101+
)}
102+
저장
103+
</Button>
104+
</form>
105+
);
106+
}

web/src/components/auth/AuthModalContainer.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useAuthModalStore } from "@/stores/useAuthModalStore";
1111
import { useToastStore } from "@/stores/useToastStore";
1212
import SignInForm from "@/components/auth/SignInForm";
1313
import SignUpForm from "@/components/auth/SignUpForm";
14+
import SocialLoginButtons from "@/components/auth/SocialLoginButtons";
1415
import Loader from "@/components/common/Loader";
1516
import { authClient } from "@/lib/auth-client";
1617
import { Dialog, DialogContent } from "@/components/ui/dialog";
@@ -105,6 +106,12 @@ export default function AuthModalContainer() {
105106
<SignUpForm error={error} onSubmit={handleSignUp} />
106107
)}
107108

109+
{!isLoading && (
110+
<div className="mt-4 pt-4 border-t border-border">
111+
<SocialLoginButtons />
112+
</div>
113+
)}
114+
108115
<Button
109116
variant="link"
110117
onClick={() =>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { authClient } from "@/lib/auth-client";
4+
import { Button } from "@/components/ui/button";
5+
6+
const SOCIAL_PROVIDERS = [
7+
{ provider: "github" as const, label: "GitHub" },
8+
{ provider: "google" as const, label: "Google" },
9+
{ provider: "naver" as const, label: "네이버" },
10+
{ provider: "kakao" as const, label: "카카오" },
11+
] as const;
12+
13+
/**
14+
* 소셜 로그인 버튼 그룹.
15+
* 로그인/회원가입 모달에서 공통 사용.
16+
* OAuth 리다이렉트 후 /auth/complete에서 역할 기반 리다이렉트 처리.
17+
*/
18+
export default function SocialLoginButtons() {
19+
const handleSocialSignIn = (
20+
provider: "github" | "google" | "naver" | "kakao"
21+
) => {
22+
authClient.signIn.social({
23+
provider,
24+
callbackURL: "/auth/complete",
25+
});
26+
};
27+
28+
return (
29+
<div className="flex flex-col gap-2 w-full">
30+
<p className="text-sm text-muted-foreground text-center">
31+
또는 소셜 계정으로 계속하기
32+
</p>
33+
<div className="grid grid-cols-2 gap-2">
34+
{SOCIAL_PROVIDERS.map(({ provider, label }) => (
35+
<Button
36+
key={provider}
37+
type="button"
38+
variant="outline"
39+
className="w-full"
40+
onClick={() => handleSocialSignIn(provider)}
41+
>
42+
{label}
43+
</Button>
44+
))}
45+
</div>
46+
</div>
47+
);
48+
}

0 commit comments

Comments
 (0)