diff --git a/src/api/auth.ts b/src/api/auth.ts index 4efbb17..920fcf9 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,5 @@ import { apiClient } from './client'; +import { useAuthStore } from '@/store/authStore'; import type { SendVerificationRequest, SendVerificationResponse, @@ -14,6 +15,10 @@ import type { RefreshTokenResponse, CheckNicknameRequest, CheckNicknameResponse, + KakaoLoginRequest, + KakaoLoginResponse, + KakaoSignupRequest, + KakaoSignupResponse, } from '@/types/api'; // 회원가입 - 인증번호 발송 @@ -61,10 +66,12 @@ export const login = async (data: LoginRequest): Promise => { try { const response = await apiClient.post('/auth/login', data); - // 💡 안전한 토큰 저장을 위해 분기 처리 유지 - if (typeof window !== 'undefined' && response.data.data) { - localStorage.setItem('accessToken', response.data.data.accessToken); - localStorage.setItem('refreshToken', response.data.data.refreshToken); + // 전역 상태에 토큰 저장 + if (response.data.data) { + useAuthStore.getState().setTokens( + response.data.data.accessToken, + response.data.data.refreshToken + ); } return response.data; @@ -78,9 +85,13 @@ export const refreshAccessToken = async (data: RefreshTokenRequest): Promise('/auth/refresh', data); - // 💡 재발급 받은 토큰도 다시 저장해줘야 합니다. - if (typeof window !== 'undefined' && response.data.data) { - localStorage.setItem('accessToken', response.data.data.accessToken); + // 재발급 받은 토큰을 전역 상태에 저장 + if (response.data.data) { + const { accessToken, refreshToken: newRefreshToken } = response.data.data; + useAuthStore.getState().setTokens( + accessToken, + newRefreshToken || data.refreshToken // 새 리프레시 토큰이 없으면 기존 것 유지 + ); } return response.data; @@ -97,4 +108,50 @@ export const checkNickname = async (data: CheckNicknameRequest): Promise => { + try { + const response = await apiClient.post('/auth/kakao/login', data); + + // localStorage에 직접 토큰 저장 + if (response.data.data) { + const { accessToken, refreshToken } = response.data.data; + + if (accessToken && refreshToken) { + // localStorage에 직접 저장 + if (typeof window !== 'undefined') { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + + // authStore에도 동기화 + useAuthStore.getState().setTokens(accessToken, refreshToken); + } + } + } + + return response.data; + } catch (error: any) { + throw error.response?.data || { message: '카카오 로그인에 실패했습니다.' }; + } +}; + +// 카카오 회원가입 +export const kakaoSignup = async (data: KakaoSignupRequest): Promise => { + try { + const response = await apiClient.post('/auth/kakao/signup', data); + + // 전역 상태에 토큰 저장 + if (response.data.data) { + useAuthStore.getState().setTokens( + response.data.data.accessToken, + response.data.data.refreshToken + ); + } + + return response.data; + } catch (error: any) { + throw error.response?.data || { message: '카카오 회원가입에 실패했습니다.' }; + } }; \ No newline at end of file diff --git a/src/app/(auth)/auth/kakao/callback/page.tsx b/src/app/(auth)/auth/kakao/callback/page.tsx new file mode 100644 index 0000000..e985d96 --- /dev/null +++ b/src/app/(auth)/auth/kakao/callback/page.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { kakaoLogin } from '@/api/auth'; + +function KakaoCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const handleCallback = async () => { + try { + // URL에서 code 추출 + const code = searchParams.get('code'); + const error = searchParams.get('error'); + + // 에러가 있으면 처리 + if (error) { + alert('카카오 로그인에 실패했습니다.'); + router.push('/'); + return; + } + + // code가 없으면 에러 + if (!code) { + alert('카카오 인증 코드를 받지 못했습니다.'); + router.push('/'); + return; + } + + // 카카오 로그인 API 호출 + const response = await kakaoLogin({ code }); + + // 신규 가입자면 회원가입으로, 기존 사용자면 홈으로 + if (response.data?.isNewUser) { + // 신규 가입자 - 회원가입 페이지로 kakaoId 전달 + router.push(`/signup?kakaoId=${response.data.kakaoId}`); + } else { + // 기존 사용자 - 홈으로 이동 + router.push('/home'); + } + } catch (error: any) { + alert(error.message || '카카오 로그인에 실패했습니다.'); + router.push('/'); + } + }; + + handleCallback(); + }, [searchParams, router]); + + // 로딩 화면 + return ( +
+
카카오 로그인 처리 중...
+
+ ); +} + +export default function KakaoCallbackPage() { + return ( + +
로딩 중...
+ + } + > + +
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 39e3bc9..f09e5a8 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,13 +1,19 @@ 'use client'; import Image from 'next/image'; -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useState, useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; import AuthContainer from '../../(auth)/AuthContainer'; -import { sendVerificationCode, verifyCode, checkNickname } from '@/api/auth'; +import { sendVerificationCode, verifyCode, checkNickname, kakaoSignup } from '@/api/auth'; -export default function SignupPage() { +function SignupPageContent() { const router = useRouter(); + const searchParams = useSearchParams(); + + // 카카오 회원가입 모드 확인 + const kakaoId = searchParams.get('kakaoId'); + const isKakaoMode = !!kakaoId; + const [step, setStep] = useState(1); const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -215,7 +221,25 @@ const handleVerifyCode = async () => { } }; - const handleNext = () => { + const handleNext = async () => { + // 카카오 모드이고 step 1(닉네임 입력) 완료 시 카카오 회원가입 API 호출 + if (isKakaoMode && step === 1 && isNicknameChecked) { + try { + setLoading(true); + await kakaoSignup({ + kakaoId: kakaoId!, + nickname: name.trim(), + }); + // 회원가입 성공 시 홈으로 이동 + router.push('/home'); + } catch (err: any) { + setNicknameError(err.message || '회원가입에 실패했습니다.'); + setLoading(false); + } + return; + } + + // 일반 회원가입 플로우 if (step < 4) { setStep(step + 1); } else { @@ -236,6 +260,11 @@ const handleVerifyCode = async () => { }; const renderStepContent = () => { + // 카카오 모드일 때는 step 1만 보여줌 + if (isKakaoMode && step !== 1) { + return null; + } + switch (step) { case 1: return ( @@ -951,15 +980,17 @@ const handleVerifyCode = async () => { return ( { false } onNext={() => { + // 카카오 모드일 때는 step 1에서 바로 회원가입 처리 + if (isKakaoMode && step === 1) { + handleNext(); + return; + } + if (step === 2) { if (!isCodeSent) { handleSendVerificationCode(); @@ -996,4 +1033,24 @@ const handleVerifyCode = async () => { {renderStepContent()} ); +} + +export default function SignupPage() { + return ( + +

로딩 중...

+ + }> + +
+ ); } \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index b1f51c2..f59d28e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -75,8 +75,22 @@ function LoginSelectScreen() { const handleKakaoLogin = () => { setLoading(true); - alert('카카오 로그인 (개발 중)'); - setLoading(false); + + // 카카오 OAuth 인증 URL 생성 + const kakaoClientId = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID; + // 프론트엔드 callback URL 사용 + const redirectUri = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI || + `${window.location.origin}/auth/kakao/callback`; + + if (!kakaoClientId) { + alert('카카오 로그인 설정이 완료되지 않았습니다.'); + setLoading(false); + return; + } + + // 카카오 인증 페이지로 리다이렉트 + const kakaoAuthUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${kakaoClientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`; + window.location.href = kakaoAuthUrl; }; return ( diff --git a/src/store/authStore.ts b/src/store/authStore.ts new file mode 100644 index 0000000..d17c03e --- /dev/null +++ b/src/store/authStore.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand'; + +interface AuthState { + // 액션 + setTokens: (accessToken: string, refreshToken: string) => void; + clearTokens: () => void; + getAccessToken: () => string | null; + getRefreshToken: () => string | null; + isAuthenticated: () => boolean; +} + +export const useAuthStore = create()((set, get) => ({ + setTokens: (accessToken: string, refreshToken: string) => { + // localStorage에 직접 저장 (페이지 이동 후에도 유지) + if (typeof window !== 'undefined') { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + } + }, + + clearTokens: () => { + // localStorage에서 삭제 + if (typeof window !== 'undefined') { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }, + + getAccessToken: () => { + if (typeof window !== 'undefined') { + return localStorage.getItem('accessToken'); + } + return null; + }, + + getRefreshToken: () => { + if (typeof window !== 'undefined') { + return localStorage.getItem('refreshToken'); + } + return null; + }, + + isAuthenticated: () => { + if (typeof window !== 'undefined') { + return !!localStorage.getItem('accessToken'); + } + return false; + }, +})); diff --git a/src/types/api.ts b/src/types/api.ts index a8d880a..3874d07 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -76,4 +76,33 @@ export interface CheckNicknameRequest { export interface CheckNicknameResponse { status: string; data: object; +} + +// 카카오 로그인 +export interface KakaoLoginRequest { + code: string; +} + +export interface KakaoLoginResponse { + status: string; + data: { + accessToken: string; + refreshToken: string; + kakaoId: string; + isNewUser: boolean; + }; +} + +// 카카오 회원가입 +export interface KakaoSignupRequest { + kakaoId: string; + nickname: string; +} + +export interface KakaoSignupResponse { + status: string; + data: { + accessToken: string; + refreshToken: string; + }; } \ No newline at end of file