diff --git a/public/icons/resizable/icon-google-login.svg b/public/icons/resizable/icon-google-login.svg new file mode 100644 index 00000000..edbc1c6a --- /dev/null +++ b/public/icons/resizable/icon-google-login.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/icons/sprite.svg b/public/icons/sprite.svg index b12042a9..5f28e6b3 100644 --- a/public/icons/sprite.svg +++ b/public/icons/sprite.svg @@ -163,6 +163,29 @@ /> + + + + + + + + + + + - + - - + + - + - + diff --git a/src/api/service/auth-service/index.ts b/src/api/service/auth-service/index.ts index 15258825..8201df94 100644 --- a/src/api/service/auth-service/index.ts +++ b/src/api/service/auth-service/index.ts @@ -1,6 +1,8 @@ import { api } from '@/api/core'; import { clearAccessToken, setAccessToken } from '@/lib/auth/token'; import { + GoogleOAuthExchangeRequest, + GoogleOAuthExchangeResponse, LoginRequest, LoginResponse, RefreshResponse, @@ -43,4 +45,15 @@ export const authServiceRemote = () => ({ await api.delete('/auth/withdraw', { withCredentials: true }); clearAccessToken(); }, + + // 구글 OAuth 코드 교환 + exchangeGoogleCode: async (payload: GoogleOAuthExchangeRequest) => { + const data = await api.post('/auth/google', payload, { + withCredentials: true, + }); + + setAccessToken(data.accessToken, data.expiresIn); + + return data; + }, }); diff --git a/src/app/auth/google/callback/page.tsx b/src/app/auth/google/callback/page.tsx new file mode 100644 index 00000000..e0d9c3ea --- /dev/null +++ b/src/app/auth/google/callback/page.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; + +import { useEffect } from 'react'; + +import { API } from '@/api'; +import { normalizePath } from '@/lib/auth/utils'; + +export default function GoogleCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const code = searchParams.get('code'); + const returnedState = searchParams.get('state'); + const error = searchParams.get('error'); + + const cleanupOAuthState = () => { + sessionStorage.removeItem('google_oauth_state'); + }; + + if (error) { + cleanupOAuthState(); + router.replace(`/login?error=${encodeURIComponent(error)}`); + return; + } + + if (!code || !returnedState) { + cleanupOAuthState(); + router.replace('/login?error=missing_code'); + return; + } + + const savedState = sessionStorage.getItem('google_oauth_state'); + if (!savedState || savedState !== returnedState) { + cleanupOAuthState(); + router.replace('/login?error=invalid_state'); + return; + } + + const nextPath = normalizePath(sessionStorage.getItem('post_login_path')); + + const exchange = async () => { + try { + const redirectUri = `${window.location.origin}/auth/google/callback`; + + await API.authService.exchangeGoogleCode({ + authorizationCode: code, + redirectUri, + }); + + cleanupOAuthState(); + + sessionStorage.removeItem('post_login_path'); + + router.replace(nextPath); + } catch { + router.replace(`/login?error=network_error&path=${nextPath}`); + } finally { + cleanupOAuthState(); + } + }; + + exchange(); + }, [router, searchParams]); + + return null; +} diff --git a/src/app/auth/privacy/page.tsx b/src/app/auth/privacy/page.tsx new file mode 100644 index 00000000..2a3d41a0 --- /dev/null +++ b/src/app/auth/privacy/page.tsx @@ -0,0 +1,83 @@ +import { ReactNode } from 'react'; + +const Title = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const SubTitle = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const Contents = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +const PrivacyPage = () => { + return ( +
+ 서비스 이용 약관 + +
+
+ 1. 개인정보 수집 및 이용 + WeGo는 서비스 제공을 위해 최소한의 개인정보를 수집합니다. +
+ +
+ 수집하는 정보 + +
    +
  • 이메일 주소
  • +
+
+
+ +
+ 이용 목적 + +
    +
  • 회원 가입 및 본인 확인
  • +
  • 서비스 이용에 따른 알림 발송
  • +
  • 모임 관련 정보 제공
  • +
+
+
+ +
+ 개인정보 보호 + +

수집된 이메일 주소는 서비스 제공 목적 외에 절대 사용하지 않습니다.

+
    +
  • 제3자에게 제공하거나 판매하지 않습니다
  • +
  • 마케팅 목적으로 사용하지 않습니다
  • +
+
+
+ +
+ 2. 서비스 이용 + +
    +
  • 본 서비스는 모임 관리를 위한 플랫폼입니다
  • +
  • 타인에게 피해를 주는 행위는 금지됩니다
  • +
  • 서비스의 정상적인 운영을 방해하는 행위는 제재 대상입니다
  • +
+
+
+ +
+ 3. 회원 탈퇴 + +
    +
  • 언제든지 회원 탈퇴가 가능합니다
  • +
  • 탈퇴 시 개인정보는 즉시 삭제됩니다
  • +
  • 관련 법령에 따라 보관이 필요한 경우에만 일정 기간 보관 후 삭제됩니다
  • +
+
+
+
+
+ ); +}; + +export default PrivacyPage; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 634d56f0..d6815429 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,19 +1,12 @@ -import { cookies } from 'next/headers'; - import { Icon } from '@/components/icon'; import { LoginForm, LoginToastEffect } from '@/components/pages/auth'; import { AuthSwitch } from '@/components/shared'; -import LoginTempActions from './_temp/login-temp-actions'; - type PageProps = { searchParams: Promise>; }; const LoginPage = async ({ searchParams }: PageProps) => { - const cookieStore = await cookies(); - const accessToken = cookieStore.get('accessToken')?.value; - const searchParamsData = await searchParams; return ( @@ -24,8 +17,6 @@ const LoginPage = async ({ searchParams }: PageProps) => { - {/* 📜 임시, 삭제 예정 */} - {accessToken && } ); }; diff --git a/src/components/icon/index.tsx b/src/components/icon/index.tsx index 521ebe2f..f26d295b 100644 --- a/src/components/icon/index.tsx +++ b/src/components/icon/index.tsx @@ -39,6 +39,7 @@ export type ResizableIconId = | 'bell-unread' | 'congratulate' | 'empty' + | 'google-login' | 'kick' | 'not-found' | 'plus-circle' @@ -198,6 +199,10 @@ export const iconMetadataMap: IconMetadata[] = [ id: 'empty', variant: 'resizable', }, + { + id: 'google-login', + variant: 'resizable', + }, { id: 'kick', variant: 'resizable', diff --git a/src/components/pages/auth/login/login-form/index.tsx b/src/components/pages/auth/login/login-form/index.tsx index 538cb80c..eec0ea1a 100644 --- a/src/components/pages/auth/login/login-form/index.tsx +++ b/src/components/pages/auth/login/login-form/index.tsx @@ -6,9 +6,11 @@ import { useForm, useStore } from '@tanstack/react-form'; import { EmailField, PasswordField } from '@/components/pages/auth/fields'; import { useLogin } from '@/hooks/use-auth'; +import { normalizePath } from '@/lib/auth/utils'; import { loginSchema } from '@/lib/schema/auth'; import { AuthSubmitButton } from '../../auth-button'; +import { LoginSnsButton } from '../login-sns-button'; export const LoginForm = () => { const { handleLogin, loginError, clearLoginError } = useLogin(); @@ -38,6 +40,35 @@ export const LoginForm = () => { clearLoginError(); }, [email, password, clearLoginError]); + const handleGoogleLogin = () => { + const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; + + if (!GOOGLE_CLIENT_ID) { + console.error('NEXT_PUBLIC_GOOGLE_CLIENT_ID is missing'); + return; + } + + const currentPathParam = new URLSearchParams(window.location.search).get('path'); + sessionStorage.setItem('post_login_path', normalizePath(currentPathParam)); + + const redirectUri = `${window.location.origin}/auth/google/callback`; + + const state = crypto.randomUUID(); + sessionStorage.setItem('google_oauth_state', state); + + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + url.search = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + prompt: 'select_account', + }).toString(); + + window.location.assign(url.toString()); + }; + return (
{ children={(state) => } selector={(state) => state} /> + Google로 로그인하기 {loginError &&

{loginError}

} diff --git a/src/components/pages/auth/login/login-sns-button/index.tsx b/src/components/pages/auth/login/login-sns-button/index.tsx new file mode 100644 index 00000000..b80738a0 --- /dev/null +++ b/src/components/pages/auth/login/login-sns-button/index.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { Icon } from '@/components/icon'; +import { Button } from '@/components/ui'; + +interface Props { + children: React.ReactNode; + onClick?: () => void; +} + +export const LoginSnsButton = ({ children, onClick }: Props) => { + return ( + + ); +}; diff --git a/src/hooks/use-auth/use-auth-login/index.ts b/src/hooks/use-auth/use-auth-login/index.ts index a2f740e9..0d5f2d0f 100644 --- a/src/hooks/use-auth/use-auth-login/index.ts +++ b/src/hooks/use-auth/use-auth-login/index.ts @@ -5,23 +5,13 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useState } from 'react'; import axios, { AxiosError } from 'axios'; -import Cookies from 'js-cookie'; import { API } from '@/api'; +import { normalizePath } from '@/lib/auth/utils'; import { useAuth } from '@/providers'; import { LoginRequest } from '@/types/service/auth'; import { CommonErrorResponse } from '@/types/service/common'; -const normalizePath = (raw: string | null) => { - const value = (raw ?? '').trim(); - - if (!value) return '/'; - - if (value.startsWith('//') || value.includes('://')) return '/'; - - return value.startsWith('/') ? value : `/${value}`; -}; - const getLoginErrorMessage = (problem: CommonErrorResponse) => { if ( problem.errorCode === 'USER_NOT_FOUND' || @@ -35,17 +25,17 @@ const getLoginErrorMessage = (problem: CommonErrorResponse) => { }; // 📜 proxy 설정 후 삭제 -const isCommonErrorResponse = (e: unknown): e is CommonErrorResponse => { - if (!e || typeof e !== 'object') return false; - - const obj = e as Record; - return ( - typeof obj.status === 'number' && - typeof obj.detail === 'string' && - typeof obj.errorCode === 'string' && - typeof obj.instance === 'string' - ); -}; +// const isCommonErrorResponse = (e: unknown): e is CommonErrorResponse => { +// if (!e || typeof e !== 'object') return false; + +// const obj = e as Record; +// return ( +// typeof obj.status === 'number' && +// typeof obj.detail === 'string' && +// typeof obj.errorCode === 'string' && +// typeof obj.instance === 'string' +// ); +// }; export const useLogin = () => { const searchParams = useSearchParams(); @@ -59,15 +49,7 @@ export const useLogin = () => { setLoginError(null); try { - const result = await API.authService.login(payload); - // 📜 추후 삭제 - console.log('login success:', result); - - Cookies.set('userId', String(result.user.userId), { - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); + await API.authService.login(payload); formApi.reset(); @@ -78,11 +60,11 @@ export const useLogin = () => { router.replace(nextPath); } catch (error) { - if (isCommonErrorResponse(error)) { - console.error('[LOGIN ERROR]', error.errorCode, error.detail); - setLoginError(getLoginErrorMessage(error)); - return; - } + // if (isCommonErrorResponse(error)) { + // console.error('[LOGIN ERROR]', error.errorCode, error.detail); + // setLoginError(getLoginErrorMessage(error)); + // return; + // } if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts index ec007823..102bc978 100644 --- a/src/lib/auth/utils.ts +++ b/src/lib/auth/utils.ts @@ -11,3 +11,13 @@ export const getHintMessage = (field: AnyFieldApi) => { return showError ? firstError?.message : undefined; }; + +export const normalizePath = (raw: string | null) => { + const value = (raw ?? '').trim(); + + if (!value) return '/'; + + if (value.startsWith('//') || value.includes('://')) return '/'; + + return value.startsWith('/') ? value : `/${value}`; +}; diff --git a/src/mock/service/auth/auth-handlers.ts b/src/mock/service/auth/auth-handlers.ts index 71e6d4c2..e2e74ce6 100644 --- a/src/mock/service/auth/auth-handlers.ts +++ b/src/mock/service/auth/auth-handlers.ts @@ -1,6 +1,8 @@ import { http, HttpResponse } from 'msw'; import { + GoogleOAuthExchangeRequest, + GoogleOAuthExchangeResponse, LoginRequest, LoginResponse, RefreshResponse, @@ -105,4 +107,53 @@ const withdrawMock = http.delete('*/api/v1/auth/withdraw', async () => { return HttpResponse.json(createMockSuccessResponse(undefined)); }); -export const authHandlers = [signupMock, loginMock, logoutMock, refreshMock, withdrawMock]; +// 구글 OAuth 코드 교환 +const exchangeGoogleCodeMock = http.post('*/api/v1/auth/google', async ({ request }) => { + const body = (await request.json()) as GoogleOAuthExchangeRequest; + + if (!body.authorizationCode || !body.redirectUri) { + return HttpResponse.json( + createMockErrorResponse({ + status: 400, + detail: 'authorizationCode 또는 redirectUri가 누락되었습니다.', + errorCode: 'A005', + }), + { status: 400 }, + ); + } + + const response: GoogleOAuthExchangeResponse = { + accessToken: 'mock-google-access-token', + tokenType: 'Bearer', + expiresIn: 3600, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + user: { + userId: 999, + email: 'googleuser@test.com', + nickName: '구글유저', + mbti: null, + profileImage: null, + profileMessage: null, + followeesCnt: 0, + followersCnt: 0, + groupJoinedCnt: 0, + groupCreatedCnt: 0, + isNotificationEnabled: true, + isFollow: false, + createdAt: new Date().toISOString(), + }, + }; + + return HttpResponse.json(createMockSuccessResponse(response), { + status: 200, + }); +}); + +export const authHandlers = [ + signupMock, + loginMock, + logoutMock, + refreshMock, + withdrawMock, + exchangeGoogleCodeMock, +]; diff --git a/src/types/service/auth.ts b/src/types/service/auth.ts index bb803c93..459d2246 100644 --- a/src/types/service/auth.ts +++ b/src/types/service/auth.ts @@ -33,3 +33,30 @@ export interface RefreshResponse { expiresIn: number; expiresAt: string; } + +export interface GoogleOAuthExchangeRequest { + authorizationCode: string; + redirectUri: string; +} + +export interface GoogleOAuthExchangeResponse { + accessToken: string; + tokenType: string; + expiresIn: number; + expiresAt: string; + user: { + userId: number; + email: string; + nickName: string; + mbti: string | null; + profileImage: string | null; + profileMessage: string | null; + followeesCnt: number; + followersCnt: number; + groupJoinedCnt: number; + groupCreatedCnt: number; + isNotificationEnabled: boolean; + isFollow: boolean; + createdAt: string; + }; +}