Skip to content

Commit ca68b86

Browse files
committed
fix: signupToken 기반 회원가입 플로우 적용
1 parent 9b9c5c5 commit ca68b86

7 files changed

Lines changed: 166 additions & 25 deletions

File tree

src/app/auth/callback/page.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
66

77
import { ApiError } from "@/services/apiClient";
88
import { exchangeToken } from "@/services/authApi";
9+
import { useAuthSignupStore } from "@/stores/authSignupStore";
910
import { useAuthStore } from "@/stores/useAuthStore";
1011

1112
interface Guide {
@@ -16,6 +17,9 @@ interface Guide {
1617
export default function AuthCallbackPage() {
1718
const router = useRouter();
1819
const setAuth = useAuthStore(s => s.setAuth);
20+
const clearAuth = useAuthStore(s => s.clearAuth);
21+
const resetSignup = useAuthSignupStore(s => s.reset);
22+
const setSignupToken = useAuthSignupStore(s => s.setSignupToken);
1923
const [guide, setGuide] = useState<Guide | null>(null);
2024
const handled = useRef(false);
2125

@@ -33,11 +37,15 @@ export default function AuthCallbackPage() {
3337
.then(res => {
3438
switch (res.loginStatus) {
3539
case "LOGIN_SUCCESS":
36-
if (res.accessToken) setAuth({ accessToken: res.accessToken, userId: res.userId });
40+
resetSignup();
41+
if (res.accessToken && res.userId) {
42+
setAuth({ accessToken: res.accessToken, userId: res.userId });
43+
}
3744
router.replace("/");
3845
break;
3946
case "SIGNUP_REQUIRED":
40-
if (res.accessToken) setAuth({ accessToken: res.accessToken, userId: res.userId });
47+
clearAuth();
48+
setSignupToken(res.signupToken);
4149
router.replace("/auth/signup/profile");
4250
break;
4351
case "LOGIN_METHOD_GUIDE":
@@ -56,7 +64,7 @@ export default function AuthCallbackPage() {
5664
const code = err instanceof ApiError ? err.code : "UNKNOWN_ERROR";
5765
router.replace(`/auth/error?error.code=${encodeURIComponent(code)}`);
5866
});
59-
}, [router, setAuth]);
67+
}, [clearAuth, resetSignup, router, setAuth, setSignupToken]);
6068

6169
if (guide) {
6270
return (

src/app/auth/signup/profile/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import AuthLayout from "@/components/auth/AuthLayout";
1010
import AuthTextField from "@/components/auth/AuthTextField";
1111
import ProfileAvatarInput from "@/components/auth/ProfileAvatarInput";
1212
import { PROFILE_VALIDATION_MESSAGES } from "@/constants/profile";
13+
import { useOnboardingGuard } from "@/hooks/useOnboardingGuard";
1314
import { ApiError } from "@/services/apiClient";
1415
import { checkNickname } from "@/services/userApi";
1516
import { useAuthSignupStore } from "@/stores/authSignupStore";
1617

1718
export default function ProfilePage() {
1819
const router = useRouter();
20+
const { canRender } = useOnboardingGuard();
1921
const { nickname, bio, profileImage, setNickname, setBio, setProfileImage } =
2022
useAuthSignupStore();
2123

@@ -93,6 +95,8 @@ export default function ProfilePage() {
9395
setErrors(prev => ({ ...prev, profileImage: message ?? undefined }));
9496
};
9597

98+
if (!canRender) return null;
99+
96100
return (
97101
<AuthLayout
98102
title="프로필 설정하기"

src/app/auth/signup/role/page.tsx

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ import { useRouter } from "next/navigation";
88
import AuthButton from "@/components/auth/AuthButton";
99
import AuthLayout from "@/components/auth/AuthLayout";
1010
import RoleSelect from "@/components/auth/RoleSelect";
11+
import { useOnboardingGuard } from "@/hooks/useOnboardingGuard";
1112
import { ApiError } from "@/services/apiClient";
1213
import { confirmImageUpload, issueImageUploadUrl, uploadImageToStorage } from "@/services/imageApi";
13-
import { completeWelcome } from "@/services/userApi";
14+
import { completeWelcome, updateProfile } from "@/services/userApi";
1415
import { useAuthSignupStore } from "@/stores/authSignupStore";
16+
import { useAuthStore } from "@/stores/useAuthStore";
1517

1618
export default function RolePage() {
1719
const router = useRouter();
18-
const { nickname, bio, role, profileImage, setRole, reset } = useAuthSignupStore();
20+
const { canRender } = useOnboardingGuard();
21+
const { nickname, bio, role, profileImage, signupToken, setRole, reset } = useAuthSignupStore();
22+
const setAuth = useAuthStore(s => s.setAuth);
1923
const [errorMessage, setErrorMessage] = useState<string | null>(null);
2024

2125
const welcomeMutation = useMutation({
@@ -24,31 +28,43 @@ export default function RolePage() {
2428
throw new Error("역할을 선택해주세요.");
2529
}
2630

27-
let profileImageId: number | null = null;
28-
29-
if (profileImage) {
30-
const uploadInfo = await issueImageUploadUrl({
31-
filename: profileImage.name,
32-
contentType: profileImage.type,
33-
fileSize: profileImage.size,
34-
});
35-
const uploadResponse = await uploadImageToStorage(uploadInfo.uploadUrl, profileImage);
36-
37-
if (!uploadResponse.ok) {
38-
throw new Error("프로필 이미지 업로드 중 오류가 발생했습니다.");
39-
}
40-
41-
await confirmImageUpload(uploadInfo.imageId);
42-
43-
profileImageId = uploadInfo.imageId;
31+
if (!signupToken) {
32+
throw new Error("회원가입 인증 정보가 만료되었습니다. 다시 로그인해주세요.");
4433
}
4534

46-
return completeWelcome({
35+
const welcomeResponse = await completeWelcome({
36+
signupToken,
4737
nickname: nickname.trim(),
4838
bio: bio.trim(),
4939
role,
50-
profileImageId,
40+
profileImageId: null,
5141
});
42+
43+
setAuth({ accessToken: welcomeResponse.accessToken, userId: welcomeResponse.userId });
44+
45+
if (profileImage) {
46+
try {
47+
const uploadInfo = await issueImageUploadUrl({
48+
filename: profileImage.name,
49+
contentType: profileImage.type,
50+
fileSize: profileImage.size,
51+
});
52+
const uploadResponse = await uploadImageToStorage(uploadInfo.uploadUrl, profileImage);
53+
54+
if (uploadResponse.ok) {
55+
const confirmedImage = await confirmImageUpload(uploadInfo.imageId);
56+
await updateProfile({
57+
bio: bio.trim(),
58+
snsUrl: null,
59+
profileImageUrl: confirmedImage.imageUrl,
60+
});
61+
}
62+
} catch {
63+
// 프로필 이미지는 선택 항목이므로 계정 생성 성공 후 업로드 실패가 가입 완료를 막지 않는다.
64+
}
65+
}
66+
67+
return welcomeResponse;
5268
},
5369
onSuccess: () => {
5470
reset();
@@ -81,6 +97,8 @@ export default function RolePage() {
8197

8298
const isSubmitDisabled = !role || welcomeMutation.isPending;
8399

100+
if (!canRender) return null;
101+
84102
return (
85103
<AuthLayout
86104
title="역할 선택하기"

src/hooks/useOnboardingGuard.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
5+
import { useQuery } from "@tanstack/react-query";
6+
import { useRouter } from "next/navigation";
7+
8+
import { getMe } from "@/services/authApi";
9+
import { useAuthSignupStore } from "@/stores/authSignupStore";
10+
import { useAuthStore } from "@/stores/useAuthStore";
11+
12+
const AUTH_STORAGE_KEY = "refit-auth";
13+
14+
function getStoredAccessToken(): string | null {
15+
if (typeof window === "undefined") return null;
16+
17+
try {
18+
const rawAuth = window.localStorage.getItem(AUTH_STORAGE_KEY);
19+
if (!rawAuth) return null;
20+
21+
const parsedAuth = JSON.parse(rawAuth) as {
22+
state?: { accessToken?: string | null; userId?: number | null };
23+
};
24+
return parsedAuth.state?.accessToken ?? null;
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
export function useOnboardingGuard(redirectTo = "/") {
31+
const router = useRouter();
32+
const signupToken = useAuthSignupStore(state => state.signupToken);
33+
const accessToken = useAuthStore(state => state.accessToken);
34+
const [hasStoredAccessToken, setHasStoredAccessToken] = useState(false);
35+
const [isAuthReady, setIsAuthReady] = useState(false);
36+
37+
useEffect(() => {
38+
const syncStoredAuth = () => {
39+
const storedAccessToken = getStoredAccessToken();
40+
41+
setHasStoredAccessToken(Boolean(storedAccessToken));
42+
43+
if (!useAuthStore.getState().accessToken && storedAccessToken) {
44+
useAuthStore.setState({ accessToken: storedAccessToken });
45+
}
46+
47+
setIsAuthReady(true);
48+
};
49+
50+
const unsubscribe = useAuthStore.persist?.onFinishHydration?.(syncStoredAuth);
51+
const hasHydrated = useAuthStore.persist?.hasHydrated?.() ?? false;
52+
53+
if (hasHydrated) {
54+
syncStoredAuth();
55+
} else {
56+
useAuthStore.persist?.rehydrate?.();
57+
}
58+
59+
return unsubscribe;
60+
}, []);
61+
62+
const canFetchMe = !signupToken && isAuthReady && Boolean(accessToken || hasStoredAccessToken);
63+
const meQuery = useQuery({
64+
queryKey: ["auth", "me"],
65+
queryFn: ({ signal }) => getMe(signal),
66+
enabled: canFetchMe,
67+
retry: false,
68+
});
69+
70+
useEffect(() => {
71+
if (signupToken || !isAuthReady) return;
72+
73+
if (!accessToken && !hasStoredAccessToken) {
74+
router.replace("/auth");
75+
return;
76+
}
77+
78+
if (meQuery.data?.signupStep === "COMPLETED") {
79+
router.replace(redirectTo);
80+
return;
81+
}
82+
83+
if (meQuery.isError) {
84+
router.replace("/auth");
85+
}
86+
}, [
87+
accessToken,
88+
hasStoredAccessToken,
89+
isAuthReady,
90+
meQuery.data?.signupStep,
91+
meQuery.isError,
92+
redirectTo,
93+
router,
94+
signupToken,
95+
]);
96+
97+
return {
98+
canRender:
99+
Boolean(signupToken) ||
100+
(canFetchMe && meQuery.isSuccess && meQuery.data.signupStep !== "COMPLETED"),
101+
};
102+
}

src/services/userApi.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface UserProfileUpdateResponse {
2828
}
2929

3030
export interface WelcomeRequest {
31+
signupToken: string;
3132
nickname: string;
3233
bio: string;
3334
role: UserRole;
@@ -40,6 +41,7 @@ export interface WelcomeResponse {
4041
nickname: string;
4142
bio: string | null;
4243
role: string;
44+
accessToken: string;
4345
profileImageUrl?: string | null;
4446
}
4547

src/stores/authSignupStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ interface AuthSignupState {
77
bio: string;
88
role: UserRole | null;
99
profileImage: File | null;
10+
signupToken: string | null;
1011

1112
setNickname: (nickname: string) => void;
1213
setBio: (bio: string) => void;
1314
setRole: (role: UserRole) => void;
1415
setProfileImage: (file: File | null) => void;
16+
setSignupToken: (signupToken: string | null) => void;
1517
reset: () => void;
1618
}
1719

@@ -20,17 +22,20 @@ export const useAuthSignupStore = create<AuthSignupState>(set => ({
2022
bio: "",
2123
role: null,
2224
profileImage: null,
25+
signupToken: null,
2326

2427
setNickname: nickname => set({ nickname }),
2528
setBio: bio => set({ bio }),
2629
setRole: role => set({ role }),
2730
setProfileImage: file => set({ profileImage: file }),
31+
setSignupToken: signupToken => set({ signupToken }),
2832

2933
reset: () =>
3034
set({
3135
nickname: "",
3236
bio: "",
3337
role: null,
3438
profileImage: null,
39+
signupToken: null,
3540
}),
3641
}));

src/types/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ export type SignupStep = "OAUTH_VERIFIED" | "PROFILE_REQUIRED" | "COMPLETED";
1212
/** POST /api/v1/auth/token 응답 data */
1313
export interface OAuthLoginResult {
1414
loginStatus: LoginStatus;
15-
userId: number;
15+
userId: number | null;
1616
registered: boolean;
1717
signupStep: SignupStep;
18+
signupToken: string | null;
1819
accessToken: string | null; // LOGIN_METHOD_GUIDE 면 null
1920
guideMessage: string | null; // LOGIN_METHOD_GUIDE 일 때만
2021
registeredProviders: string[] | null; // LOGIN_METHOD_GUIDE 일 때만
@@ -28,5 +29,6 @@ export interface MeResult {
2829
bio: string | null;
2930
profileImageUrl?: string | null;
3031
role: string;
32+
signupStep: SignupStep;
3133
snsUrl: string | null;
3234
}

0 commit comments

Comments
 (0)