Skip to content

Commit 1cda619

Browse files
authored
Merge pull request #269 from DDD-Community/feat/social-login
feat: 앱 전용 소셜 로그인 회원가입 페이지 구현
2 parents 1e1b515 + 916319c commit 1cda619

9 files changed

Lines changed: 1048 additions & 6 deletions

File tree

docs/tasks/app-oauth-signup-page.md

Lines changed: 723 additions & 0 deletions
Large diffs are not rendered by default.

src/app/(auth)/oauth/app/page.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client';
2+
3+
import { useEffect, useState, useRef } from 'react';
4+
import { useRouter } from 'next/navigation';
5+
import { appBridge } from '@/shared/lib/appBridge';
6+
import type { OAuthSignupPayload } from '@/shared/lib/appBridge';
7+
import { AppSocialSignupForm } from '@/composite/signup/signUpForm';
8+
9+
export default function OAuthAppSignupPage() {
10+
const router = useRouter();
11+
const [signupData, setSignupData] = useState<OAuthSignupPayload | null>(null);
12+
const [error, setError] = useState<string | null>(null);
13+
const dataReceivedRef = useRef(false);
14+
15+
useEffect(() => {
16+
// 1. 앱 환경이 아니면 일반 웹 회원가입으로 리다이렉트
17+
if (!appBridge.isInApp()) {
18+
router.replace('/signup');
19+
return;
20+
}
21+
22+
// 2. 앱에 준비 완료 알림
23+
appBridge.sendToApp('READY');
24+
25+
// 3. 앱에서 회원가입 데이터 수신
26+
const unsubscribe = appBridge.onAppMessage<OAuthSignupPayload>(message => {
27+
if (message.type === 'OAUTH_SIGNUP' && message.payload) {
28+
dataReceivedRef.current = true;
29+
setSignupData(message.payload);
30+
}
31+
});
32+
33+
// 4. 타임아웃 처리 (10초)
34+
const timeoutId = setTimeout(() => {
35+
if (!dataReceivedRef.current) {
36+
setError('회원가입 데이터를 받지 못했습니다.');
37+
}
38+
}, 10000);
39+
40+
return () => {
41+
unsubscribe();
42+
clearTimeout(timeoutId);
43+
};
44+
}, [router]);
45+
46+
// 에러 상태
47+
if (error) {
48+
return (
49+
<div className="flex h-screen flex-col items-center justify-center bg-normal-alternative text-white">
50+
<p className="text-lg mb-4">{error}</p>
51+
<button
52+
onClick={() => appBridge.sendToApp('NAVIGATE_TO_NATIVE_LOGIN')}
53+
className="px-6 py-3 bg-white text-black rounded-lg font-medium"
54+
>
55+
돌아가기
56+
</button>
57+
</div>
58+
);
59+
}
60+
61+
// 로딩 상태
62+
if (!signupData) {
63+
return (
64+
<div className="flex h-screen items-center justify-center bg-normal-alternative">
65+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white" />
66+
</div>
67+
);
68+
}
69+
70+
// 회원가입 폼 렌더링
71+
return (
72+
<div className="flex flex-col h-screen bg-normal-alternative">
73+
<div className="flex-1 overflow-y-auto px-5 py-6">
74+
<h1 className="text-xl font-bold text-white mb-6">회원가입</h1>
75+
<AppSocialSignupForm socialType={signupData.socialLoginType} registrationToken={signupData.registrationToken} />
76+
</div>
77+
</div>
78+
);
79+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client';
2+
3+
import { useForm, Controller } from 'react-hook-form';
4+
import { Select } from '@/shared/components/input/Select';
5+
import { InputField } from '@/shared/components/input/InputField';
6+
import Checkbox from '@/shared/components/input/Checkbox';
7+
import Badge from '@/shared/components/display/Badge';
8+
import { SelectJobResponsive } from '@/feature/auth/selectJobResponsive';
9+
import { useFetchAppSocialSignup } from './hook';
10+
import { KakaoSignupFormData, SocialLoginType } from './type';
11+
import { CAREER_YEAR_OPTIONS, CAREER_YEAR_VALUES } from './const';
12+
13+
interface AppSocialSignupFormProps {
14+
registrationToken: string;
15+
socialType: SocialLoginType;
16+
}
17+
18+
export const AppSocialSignupForm = ({ registrationToken, socialType }: AppSocialSignupFormProps) => {
19+
const { isSubmitting, fetchAppSocialSignup } = useFetchAppSocialSignup({
20+
registrationToken,
21+
socialType,
22+
});
23+
24+
const {
25+
watch,
26+
setValue,
27+
register,
28+
control,
29+
handleSubmit,
30+
formState: { errors, isValid },
31+
} = useForm<KakaoSignupFormData>({
32+
mode: 'onChange',
33+
defaultValues: {
34+
name: '',
35+
jobRoleId: '',
36+
careerYear: '',
37+
privacyPolicy: false,
38+
termsOfService: false,
39+
},
40+
});
41+
42+
const jobRoleId = watch('jobRoleId');
43+
44+
return (
45+
<form className="space-y-6 w-full">
46+
{/* 이름 */}
47+
<InputField
48+
label="이름"
49+
type="text"
50+
placeholder="성함을 입력해주세요."
51+
{...register('name', {
52+
required: '이름을 입력해주세요.',
53+
maxLength: {
54+
value: 6,
55+
message: '이름은 6자 이하이어야 합니다.',
56+
},
57+
})}
58+
isError={!!errors.name}
59+
errorMessage={errors.name?.message as string}
60+
/>
61+
62+
{/* 직무 */}
63+
<div className="space-y-2">
64+
<label className="block text-sm font-medium text-gray-300">직무</label>
65+
<SelectJobResponsive
66+
selectedJobId={jobRoleId}
67+
onJobSelect={jobId => setValue('jobRoleId', jobId, { shouldValidate: true })}
68+
/>
69+
{errors.jobRoleId && <p className="text-xs text-red-500">{errors.jobRoleId.message as string}</p>}
70+
</div>
71+
72+
{/* 연차 */}
73+
<div className="space-y-2">
74+
<label className="block text-sm font-medium text-gray-300">연차</label>
75+
<Select
76+
options={CAREER_YEAR_OPTIONS}
77+
selected={(() => {
78+
const value = watch('careerYear');
79+
const label = Object.entries(CAREER_YEAR_VALUES).find(([, val]) => val === value)?.[0];
80+
return label || '선택';
81+
})()}
82+
onChange={value =>
83+
setValue('careerYear', value === '선택' ? '' : CAREER_YEAR_VALUES[value], { shouldValidate: true })
84+
}
85+
placeholder="연차를 선택해주세요"
86+
isError={!!errors.careerYear}
87+
{...(() => {
88+
const { onChange, ...rest } = register('careerYear', { required: '연차를 선택해주세요.' });
89+
return rest;
90+
})()}
91+
/>
92+
{errors.careerYear && <p className="text-xs text-red-500">{errors.careerYear.message as string}</p>}
93+
</div>
94+
95+
{/* 약관 동의 */}
96+
<div className="space-y-2">
97+
<label className="flex items-center space-x-2">
98+
<Controller
99+
name="privacyPolicy"
100+
control={control}
101+
rules={{ required: '개인정보 수집에 동의해주세요.' }}
102+
render={({ field }) => <Checkbox checked={field.value} onChange={field.onChange} />}
103+
/>
104+
<Badge
105+
type="default"
106+
size="sm"
107+
label="필수"
108+
color="bg-[rgba(255,99,99,0.16)]"
109+
textColor="text-status-negative"
110+
/>
111+
<span className="text-gray-400 text-sm">개인정보 수집 동의</span>
112+
</label>
113+
{errors.privacyPolicy && <p className="text-xs text-red-500">{errors.privacyPolicy.message as string}</p>}
114+
115+
<label className="flex items-center space-x-2">
116+
<Controller
117+
name="termsOfService"
118+
control={control}
119+
rules={{ required: '이용 약관에 동의해주세요.' }}
120+
render={({ field }) => <Checkbox checked={field.value} onChange={field.onChange} />}
121+
/>
122+
<Badge
123+
type="default"
124+
size="sm"
125+
label="필수"
126+
color="bg-[rgba(255,99,99,0.16)]"
127+
textColor="text-status-negative"
128+
/>
129+
<span className="text-gray-400 text-sm">이용 약관 동의</span>
130+
</label>
131+
{errors.termsOfService && <p className="text-xs text-red-500">{errors.termsOfService.message as string}</p>}
132+
</div>
133+
134+
{/* 제출 버튼 */}
135+
<button
136+
type="button"
137+
disabled={!isValid || isSubmitting}
138+
onClick={handleSubmit(fetchAppSocialSignup)}
139+
className={`w-full py-3 rounded-lg font-medium ${
140+
isValid && !isSubmitting ? 'bg-primary text-white' : 'bg-gray-600 text-gray-400 cursor-not-allowed'
141+
}`}
142+
>
143+
{isSubmitting ? '가입 중...' : '가입하기'}
144+
</button>
145+
</form>
146+
);
147+
};

src/composite/signup/signUpForm/api.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { apiClient } from '@/shared/lib/apiClient';
2-
import { KakaoSignupFormData, SignupFormData } from '@/composite/signup/signUpForm/type';
3-
import { KakaoSignUpRequest, KakaoSignUpResponse } from '@/composite/signup/signUpForm/type';
2+
import {
3+
KakaoSignupFormData,
4+
SignupFormData,
5+
KakaoSignUpRequest,
6+
KakaoSignUpResponse,
7+
SocialLoginType,
8+
AppSocialSignUpResponse,
9+
} from '@/composite/signup/signUpForm/type';
410

511
interface SignUpRequest extends Omit<SignupFormData, 'privacyPolicy' | 'termsOfService'> {
612
requiredConsent: {
@@ -35,3 +41,25 @@ export async function postKakaoSignUp(req: KakaoSignupFormData, registrationToke
3541
};
3642
return await apiClient.post<KakaoSignUpRequest, KakaoSignUpResponse>('/auth/signup/kakao', request);
3743
}
44+
45+
// 앱 전용 소셜 회원가입 API
46+
export async function postAppSocialSignUp(
47+
req: KakaoSignupFormData,
48+
registrationToken: string,
49+
socialType: SocialLoginType
50+
) {
51+
const { privacyPolicy, termsOfService, ...rest } = req;
52+
const request: KakaoSignUpRequest = {
53+
registrationToken,
54+
...rest,
55+
requiredConsent: {
56+
isPrivacyPolicyAgreed: true,
57+
isServiceTermsAgreed: true,
58+
},
59+
};
60+
61+
// 소셜 타입별 엔드포인트 분기
62+
const endpoint = socialType === 'kakao' ? '/auth/signup/kakao' : '/auth/signup/apple';
63+
64+
return await apiClient.post<AppSocialSignUpResponse, KakaoSignUpRequest>(endpoint, request);
65+
}

src/composite/signup/signUpForm/hook.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState } from 'react';
22
import { AxiosError } from 'axios';
3-
import { KakaoSignupFormData, SignupFormData } from '@/composite/signup/signUpForm/type';
3+
import { KakaoSignupFormData, SignupFormData, SocialLoginType } from '@/composite/signup/signUpForm/type';
44
import { CommonError } from '@/shared/type/response';
55
import { useToast } from '@/shared/components/feedBack/toast';
6-
import { postSignUp, postKakaoSignUp } from '@/composite/signup/signUpForm/api';
6+
import { postSignUp, postKakaoSignUp, postAppSocialSignUp } from '@/composite/signup/signUpForm/api';
7+
import { authService } from '@/shared/lib/auth';
78
import { useRouter } from 'next/navigation';
89

910
export function useFetchSignUp() {
@@ -77,3 +78,43 @@ export function useFetchKakaoSignUp() {
7778
fetchKakaoSignUp,
7879
};
7980
}
81+
82+
interface UseAppSocialSignupProps {
83+
registrationToken: string;
84+
socialType: SocialLoginType;
85+
}
86+
87+
export function useFetchAppSocialSignup({ registrationToken, socialType }: UseAppSocialSignupProps) {
88+
const { showToast } = useToast();
89+
const [isSubmitting, setSubmitting] = useState<boolean>(false);
90+
const [isSignupSuccess, setSignupSuccess] = useState<boolean>(false);
91+
92+
const fetchAppSocialSignup = async (data: KakaoSignupFormData) => {
93+
try {
94+
setSubmitting(true);
95+
96+
const response = await postAppSocialSignUp(data, registrationToken, socialType);
97+
98+
// 토큰 저장 → AppBridgeProvider가 자동으로 앱에 SYNC_TOKEN_TO_APP 전송
99+
authService.login({
100+
accessToken: response.data.accessToken,
101+
refreshToken: response.data.refreshToken,
102+
});
103+
104+
setSubmitting(false);
105+
setSignupSuccess(true);
106+
} catch (error) {
107+
const axiosError = error as AxiosError<CommonError>;
108+
const errorMessage = axiosError.response?.data.message || '회원가입에 실패했습니다.';
109+
showToast(errorMessage);
110+
setSubmitting(false);
111+
setSignupSuccess(false);
112+
}
113+
};
114+
115+
return {
116+
isSubmitting,
117+
isSignupSuccess,
118+
fetchAppSocialSignup,
119+
};
120+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { SignUpForm } from './component';
2+
export { KakaoSignupForm } from './KakaoSignupForm';
3+
export { AppSocialSignupForm } from './AppSocialSignupForm';

src/composite/signup/signUpForm/type.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,15 @@ export interface KakaoSignUpRequest extends Omit<KakaoSignupFormData, 'privacyPo
2727
}
2828

2929
export interface KakaoSignUpResponse {}
30+
31+
// 앱 소셜 로그인 관련 타입
32+
export type SocialLoginType = 'kakao' | 'apple';
33+
34+
// 앱 소셜 회원가입은 KakaoSignupFormData와 동일한 구조
35+
export type AppSocialSignupFormData = KakaoSignupFormData;
36+
37+
// 앱 소셜 회원가입 응답 (토큰 포함)
38+
export interface AppSocialSignUpResponse {
39+
accessToken: string;
40+
refreshToken: string;
41+
}

src/shared/lib/appBridge/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { appBridge } from './appBridge';
2-
export type { AppMessage, AppMessageType, AppTokenPayload } from './types';
2+
export type { AppMessage, AppMessageType, AppTokenPayload, OAuthSignupPayload } from './types';

src/shared/lib/appBridge/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ export type AppMessageType =
66
| 'SYNC_TOKEN_TO_WEB' // App → Web: 앱에서 웹으로 토큰 동기화
77
| 'SYNC_TOKEN_TO_APP' // Web → App: 웹에서 앱으로 토큰 동기화 (로그인/갱신)
88
| 'LOGOUT' // Web → App: 로그아웃
9-
| 'NAVIGATE_TO_NATIVE_LOGIN'; // Web → App: 네이티브 로그인 화면으로 이동
9+
| 'NAVIGATE_TO_NATIVE_LOGIN' // Web → App: 네이티브 로그인 화면으로 이동
10+
| 'OAUTH_SIGNUP'; // App → Web: 소셜 로그인 회원가입 데이터 전달
1011

1112
/**
1213
* 메시지 구조
@@ -23,3 +24,12 @@ export interface AppTokenPayload {
2324
accessToken: string;
2425
refreshToken: string;
2526
}
27+
28+
/**
29+
* 소셜 로그인 회원가입 페이로드 (OAUTH_SIGNUP에서 사용)
30+
*/
31+
export interface OAuthSignupPayload {
32+
identityToken: string;
33+
registrationToken: string;
34+
socialLoginType: 'apple' | 'kakao';
35+
}

0 commit comments

Comments
 (0)