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
13 changes: 13 additions & 0 deletions public/icons/resizable/icon-google-login.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 29 additions & 6 deletions public/icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions src/api/service/auth-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { api } from '@/api/core';
import { clearAccessToken, setAccessToken } from '@/lib/auth/token';
import {
GoogleOAuthExchangeRequest,
GoogleOAuthExchangeResponse,
LoginRequest,
LoginResponse,
RefreshResponse,
Expand Down Expand Up @@ -43,4 +45,15 @@ export const authServiceRemote = () => ({
await api.delete<void>('/auth/withdraw', { withCredentials: true });
clearAccessToken();
},

// 구글 OAuth 코드 교환
exchangeGoogleCode: async (payload: GoogleOAuthExchangeRequest) => {
const data = await api.post<GoogleOAuthExchangeResponse>('/auth/google', payload, {
withCredentials: true,
});

setAccessToken(data.accessToken, data.expiresIn);

return data;
},
});
69 changes: 69 additions & 0 deletions src/app/auth/google/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
83 changes: 83 additions & 0 deletions src/app/auth/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ReactNode } from 'react';

const Title = ({ children }: { children: ReactNode }) => {
return <div className='text-text-sm-bold text-gray-600'>{children}</div>;
};

const SubTitle = ({ children }: { children: ReactNode }) => {
return <div className='text-text-sm-bold text-gray-600'>{children}</div>;
};

const Contents = ({ children }: { children: ReactNode }) => {
return <div className='text-text-sm-regular text-gray-600'>{children}</div>;
};

const PrivacyPage = () => {
return (
<div className='flex-col-center gap-2'>
<Title>서비스 이용 약관</Title>

<div className='space-y-2 rounded-lg p-5'>
<div className='space-y-2'>
<SubTitle>1. 개인정보 수집 및 이용</SubTitle>
<Contents>WeGo는 서비스 제공을 위해 최소한의 개인정보를 수집합니다.</Contents>
</div>

<div className='space-y-2'>
<SubTitle>수집하는 정보</SubTitle>
<Contents>
<ul className='list-disc pl-5'>
<li>이메일 주소</li>
</ul>
</Contents>
</div>

<div className='space-y-2'>
<SubTitle>이용 목적</SubTitle>
<Contents>
<ul className='list-disc pl-5'>
<li>회원 가입 및 본인 확인</li>
<li>서비스 이용에 따른 알림 발송</li>
<li>모임 관련 정보 제공</li>
</ul>
</Contents>
</div>

<div className='space-y-2'>
<SubTitle>개인정보 보호</SubTitle>
<Contents>
<p>수집된 이메일 주소는 서비스 제공 목적 외에 절대 사용하지 않습니다.</p>
<ul className='mt-2 list-disc pl-5'>
<li>제3자에게 제공하거나 판매하지 않습니다</li>
<li>마케팅 목적으로 사용하지 않습니다</li>
</ul>
</Contents>
</div>

<div className='space-y-2'>
<SubTitle>2. 서비스 이용</SubTitle>
<Contents>
<ul className='list-disc pl-5'>
<li>본 서비스는 모임 관리를 위한 플랫폼입니다</li>
<li>타인에게 피해를 주는 행위는 금지됩니다</li>
<li>서비스의 정상적인 운영을 방해하는 행위는 제재 대상입니다</li>
</ul>
</Contents>
</div>

<div className='space-y-2'>
<SubTitle>3. 회원 탈퇴</SubTitle>
<Contents>
<ul className='list-disc pl-5'>
<li>언제든지 회원 탈퇴가 가능합니다</li>
<li>탈퇴 시 개인정보는 즉시 삭제됩니다</li>
<li>관련 법령에 따라 보관이 필요한 경우에만 일정 기간 보관 후 삭제됩니다</li>
</ul>
</Contents>
</div>
</div>
</div>
);
};

export default PrivacyPage;
9 changes: 0 additions & 9 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | string[] | undefined>>;
};

const LoginPage = async ({ searchParams }: PageProps) => {
const cookieStore = await cookies();
const accessToken = cookieStore.get('accessToken')?.value;

const searchParamsData = await searchParams;

return (
Expand All @@ -24,8 +17,6 @@ const LoginPage = async ({ searchParams }: PageProps) => {
</div>
<AuthSwitch type='signup' />
<LoginToastEffect error={searchParamsData.error} />
{/* 📜 임시, 삭제 예정 */}
{accessToken && <LoginTempActions />}
</div>
);
};
Expand Down
5 changes: 5 additions & 0 deletions src/components/icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type ResizableIconId =
| 'bell-unread'
| 'congratulate'
| 'empty'
| 'google-login'
| 'kick'
| 'not-found'
| 'plus-circle'
Expand Down Expand Up @@ -198,6 +199,10 @@ export const iconMetadataMap: IconMetadata[] = [
id: 'empty',
variant: 'resizable',
},
{
id: 'google-login',
variant: 'resizable',
},
{
id: 'kick',
variant: 'resizable',
Expand Down
32 changes: 32 additions & 0 deletions src/components/pages/auth/login/login-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<form
className='flex-col-center w-full gap-8'
Expand All @@ -59,6 +90,7 @@ export const LoginForm = () => {
children={(state) => <AuthSubmitButton state={state} type='login' />}
selector={(state) => state}
/>
<LoginSnsButton onClick={handleGoogleLogin}>Google로 로그인하기</LoginSnsButton>
{loginError && <p className='text-error-500 text-text-sm-medium'>{loginError}</p>}
</div>
</form>
Expand Down
24 changes: 24 additions & 0 deletions src/components/pages/auth/login/login-sns-button/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
className='flex-center text-text-md-semibold relative text-gray-700'
size='md'
type='button'
variant='tertiary'
onClick={onClick}
>
<Icon id='google-login' className='absolute top-3.5 left-6 size-6' />
<span>{children}</span>
</Button>
);
};
Loading