Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
723 changes: 723 additions & 0 deletions docs/tasks/app-oauth-signup-page.md

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions src/app/(auth)/oauth/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { appBridge } from '@/shared/lib/appBridge';
import type { OAuthSignupPayload } from '@/shared/lib/appBridge';
import { AppSocialSignupForm } from '@/composite/signup/signUpForm';

export default function OAuthAppSignupPage() {
const router = useRouter();
const [signupData, setSignupData] = useState<OAuthSignupPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const dataReceivedRef = useRef(false);

useEffect(() => {
// 1. 앱 환경이 아니면 일반 웹 회원가입으로 리다이렉트
if (!appBridge.isInApp()) {
router.replace('/signup');
return;
}

// 2. 앱에 준비 완료 알림
appBridge.sendToApp('READY');

// 3. 앱에서 회원가입 데이터 수신
const unsubscribe = appBridge.onAppMessage<OAuthSignupPayload>(message => {
if (message.type === 'OAUTH_SIGNUP' && message.payload) {
dataReceivedRef.current = true;
setSignupData(message.payload);
}
});

// 4. 타임아웃 처리 (10초)
const timeoutId = setTimeout(() => {
if (!dataReceivedRef.current) {
setError('회원가입 데이터를 받지 못했습니다.');
}
}, 10000);

return () => {
unsubscribe();
clearTimeout(timeoutId);
};
}, [router]);

// 에러 상태
if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-normal-alternative text-white">
<p className="text-lg mb-4">{error}</p>
<button
onClick={() => appBridge.sendToApp('NAVIGATE_TO_NATIVE_LOGIN')}
className="px-6 py-3 bg-white text-black rounded-lg font-medium"
>
돌아가기
</button>
</div>
);
}

// 로딩 상태
if (!signupData) {
return (
<div className="flex h-screen items-center justify-center bg-normal-alternative">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white" />
</div>
);
}

// 회원가입 폼 렌더링
return (
<div className="flex flex-col h-screen bg-normal-alternative">
<div className="flex-1 overflow-y-auto px-5 py-6">
<h1 className="text-xl font-bold text-white mb-6">회원가입</h1>
<AppSocialSignupForm socialType={signupData.socialLoginType} registrationToken={signupData.registrationToken} />
</div>
</div>
);
}
147 changes: 147 additions & 0 deletions src/composite/signup/signUpForm/AppSocialSignupForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

import { useForm, Controller } from 'react-hook-form';
import { Select } from '@/shared/components/input/Select';
import { InputField } from '@/shared/components/input/InputField';
import Checkbox from '@/shared/components/input/Checkbox';
import Badge from '@/shared/components/display/Badge';
import { SelectJobResponsive } from '@/feature/auth/selectJobResponsive';
import { useFetchAppSocialSignup } from './hook';
import { KakaoSignupFormData, SocialLoginType } from './type';
import { CAREER_YEAR_OPTIONS, CAREER_YEAR_VALUES } from './const';

interface AppSocialSignupFormProps {
registrationToken: string;
socialType: SocialLoginType;
}

export const AppSocialSignupForm = ({ registrationToken, socialType }: AppSocialSignupFormProps) => {
const { isSubmitting, fetchAppSocialSignup } = useFetchAppSocialSignup({
registrationToken,
socialType,
});

const {
watch,
setValue,
register,
control,
handleSubmit,
formState: { errors, isValid },
} = useForm<KakaoSignupFormData>({
mode: 'onChange',
defaultValues: {
name: '',
jobRoleId: '',
careerYear: '',
privacyPolicy: false,
termsOfService: false,
},
});

const jobRoleId = watch('jobRoleId');

return (
<form className="space-y-6 w-full">
{/* 이름 */}
<InputField
label="이름"
type="text"
placeholder="성함을 입력해주세요."
{...register('name', {
required: '이름을 입력해주세요.',
maxLength: {
value: 6,
message: '이름은 6자 이하이어야 합니다.',
},
})}
isError={!!errors.name}
errorMessage={errors.name?.message as string}
/>

{/* 직무 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">직무</label>
<SelectJobResponsive
selectedJobId={jobRoleId}
onJobSelect={jobId => setValue('jobRoleId', jobId, { shouldValidate: true })}
/>
{errors.jobRoleId && <p className="text-xs text-red-500">{errors.jobRoleId.message as string}</p>}
</div>

{/* 연차 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">연차</label>
<Select
options={CAREER_YEAR_OPTIONS}
selected={(() => {
const value = watch('careerYear');
const label = Object.entries(CAREER_YEAR_VALUES).find(([, val]) => val === value)?.[0];
return label || '선택';
})()}
onChange={value =>
setValue('careerYear', value === '선택' ? '' : CAREER_YEAR_VALUES[value], { shouldValidate: true })
}
placeholder="연차를 선택해주세요"
isError={!!errors.careerYear}
{...(() => {
const { onChange, ...rest } = register('careerYear', { required: '연차를 선택해주세요.' });
return rest;
})()}
/>
{errors.careerYear && <p className="text-xs text-red-500">{errors.careerYear.message as string}</p>}
</div>

{/* 약관 동의 */}
<div className="space-y-2">
<label className="flex items-center space-x-2">
<Controller
name="privacyPolicy"
control={control}
rules={{ required: '개인정보 수집에 동의해주세요.' }}
render={({ field }) => <Checkbox checked={field.value} onChange={field.onChange} />}
/>
<Badge
type="default"
size="sm"
label="필수"
color="bg-[rgba(255,99,99,0.16)]"
textColor="text-status-negative"
/>
<span className="text-gray-400 text-sm">개인정보 수집 동의</span>
</label>
{errors.privacyPolicy && <p className="text-xs text-red-500">{errors.privacyPolicy.message as string}</p>}

<label className="flex items-center space-x-2">
<Controller
name="termsOfService"
control={control}
rules={{ required: '이용 약관에 동의해주세요.' }}
render={({ field }) => <Checkbox checked={field.value} onChange={field.onChange} />}
/>
<Badge
type="default"
size="sm"
label="필수"
color="bg-[rgba(255,99,99,0.16)]"
textColor="text-status-negative"
/>
<span className="text-gray-400 text-sm">이용 약관 동의</span>
</label>
{errors.termsOfService && <p className="text-xs text-red-500">{errors.termsOfService.message as string}</p>}
</div>

{/* 제출 버튼 */}
<button
type="button"
disabled={!isValid || isSubmitting}
onClick={handleSubmit(fetchAppSocialSignup)}
className={`w-full py-3 rounded-lg font-medium ${
isValid && !isSubmitting ? 'bg-primary text-white' : 'bg-gray-600 text-gray-400 cursor-not-allowed'
}`}
>
{isSubmitting ? '가입 중...' : '가입하기'}
</button>
</form>
);
};
32 changes: 30 additions & 2 deletions src/composite/signup/signUpForm/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { apiClient } from '@/shared/lib/apiClient';
import { KakaoSignupFormData, SignupFormData } from '@/composite/signup/signUpForm/type';
import { KakaoSignUpRequest, KakaoSignUpResponse } from '@/composite/signup/signUpForm/type';
import {
KakaoSignupFormData,
SignupFormData,
KakaoSignUpRequest,
KakaoSignUpResponse,
SocialLoginType,
AppSocialSignUpResponse,
} from '@/composite/signup/signUpForm/type';

interface SignUpRequest extends Omit<SignupFormData, 'privacyPolicy' | 'termsOfService'> {
requiredConsent: {
Expand Down Expand Up @@ -35,3 +41,25 @@ export async function postKakaoSignUp(req: KakaoSignupFormData, registrationToke
};
return await apiClient.post<KakaoSignUpRequest, KakaoSignUpResponse>('/auth/signup/kakao', request);
}

// 앱 전용 소셜 회원가입 API
export async function postAppSocialSignUp(
req: KakaoSignupFormData,
registrationToken: string,
socialType: SocialLoginType
) {
const { privacyPolicy, termsOfService, ...rest } = req;
const request: KakaoSignUpRequest = {
registrationToken,
...rest,
requiredConsent: {
isPrivacyPolicyAgreed: true,
isServiceTermsAgreed: true,
},
};

// 소셜 타입별 엔드포인트 분기
const endpoint = socialType === 'kakao' ? '/auth/signup/kakao' : '/auth/signup/apple';

return await apiClient.post<AppSocialSignUpResponse, KakaoSignUpRequest>(endpoint, request);
}
45 changes: 43 additions & 2 deletions src/composite/signup/signUpForm/hook.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { AxiosError } from 'axios';
import { KakaoSignupFormData, SignupFormData } from '@/composite/signup/signUpForm/type';
import { KakaoSignupFormData, SignupFormData, SocialLoginType } from '@/composite/signup/signUpForm/type';
import { CommonError } from '@/shared/type/response';
import { useToast } from '@/shared/components/feedBack/toast';
import { postSignUp, postKakaoSignUp } from '@/composite/signup/signUpForm/api';
import { postSignUp, postKakaoSignUp, postAppSocialSignUp } from '@/composite/signup/signUpForm/api';
import { authService } from '@/shared/lib/auth';
import { useRouter } from 'next/navigation';

export function useFetchSignUp() {
Expand Down Expand Up @@ -77,3 +78,43 @@ export function useFetchKakaoSignUp() {
fetchKakaoSignUp,
};
}

interface UseAppSocialSignupProps {
registrationToken: string;
socialType: SocialLoginType;
}

export function useFetchAppSocialSignup({ registrationToken, socialType }: UseAppSocialSignupProps) {
const { showToast } = useToast();
const [isSubmitting, setSubmitting] = useState<boolean>(false);
const [isSignupSuccess, setSignupSuccess] = useState<boolean>(false);

const fetchAppSocialSignup = async (data: KakaoSignupFormData) => {
try {
setSubmitting(true);

const response = await postAppSocialSignUp(data, registrationToken, socialType);

// 토큰 저장 → AppBridgeProvider가 자동으로 앱에 SYNC_TOKEN_TO_APP 전송
authService.login({
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken,
});

setSubmitting(false);
setSignupSuccess(true);
} catch (error) {
const axiosError = error as AxiosError<CommonError>;
const errorMessage = axiosError.response?.data.message || '회원가입에 실패했습니다.';
showToast(errorMessage);
setSubmitting(false);
setSignupSuccess(false);
}
};

return {
isSubmitting,
isSignupSuccess,
fetchAppSocialSignup,
};
}
2 changes: 2 additions & 0 deletions src/composite/signup/signUpForm/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { SignUpForm } from './component';
export { KakaoSignupForm } from './KakaoSignupForm';
export { AppSocialSignupForm } from './AppSocialSignupForm';
12 changes: 12 additions & 0 deletions src/composite/signup/signUpForm/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,15 @@ export interface KakaoSignUpRequest extends Omit<KakaoSignupFormData, 'privacyPo
}

export interface KakaoSignUpResponse {}

// 앱 소셜 로그인 관련 타입
export type SocialLoginType = 'kakao' | 'apple';

// 앱 소셜 회원가입은 KakaoSignupFormData와 동일한 구조
export type AppSocialSignupFormData = KakaoSignupFormData;

// 앱 소셜 회원가입 응답 (토큰 포함)
export interface AppSocialSignUpResponse {
accessToken: string;
refreshToken: string;
}
2 changes: 1 addition & 1 deletion src/shared/lib/appBridge/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { appBridge } from './appBridge';
export type { AppMessage, AppMessageType, AppTokenPayload } from './types';
export type { AppMessage, AppMessageType, AppTokenPayload, OAuthSignupPayload } from './types';
12 changes: 11 additions & 1 deletion src/shared/lib/appBridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type AppMessageType =
| 'SYNC_TOKEN_TO_WEB' // App → Web: 앱에서 웹으로 토큰 동기화
| 'SYNC_TOKEN_TO_APP' // Web → App: 웹에서 앱으로 토큰 동기화 (로그인/갱신)
| 'LOGOUT' // Web → App: 로그아웃
| 'NAVIGATE_TO_NATIVE_LOGIN'; // Web → App: 네이티브 로그인 화면으로 이동
| 'NAVIGATE_TO_NATIVE_LOGIN' // Web → App: 네이티브 로그인 화면으로 이동
| 'OAUTH_SIGNUP'; // App → Web: 소셜 로그인 회원가입 데이터 전달

/**
* 메시지 구조
Expand All @@ -23,3 +24,12 @@ export interface AppTokenPayload {
accessToken: string;
refreshToken: string;
}

/**
* 소셜 로그인 회원가입 페이로드 (OAUTH_SIGNUP에서 사용)
*/
export interface OAuthSignupPayload {
identityToken: string;
registrationToken: string;
socialLoginType: 'apple' | 'kakao';
}