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
2 changes: 1 addition & 1 deletion src/entities/auth/apis/get-ticket.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface GetTicketApiResponse {
}

export const getTicketApi = async (ticket: string): Promise<GetTicketApiResponse> => {
const response = await fetchInstance.get(GET_TICKET_API_PATH, {
const response = await fetchInstance.get<GetTicketApiResponse>(GET_TICKET_API_PATH, {
params: {
ticket,
},
Expand Down
1 change: 1 addition & 0 deletions src/entities/auth/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './getAuthTicket';
export * from './useKakaoLogin';
38 changes: 38 additions & 0 deletions src/entities/auth/hooks/useKakaoLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';

import { BASE_URL } from '@/shared';

import { getTicketApi } from '../apis';

// --- 1. "๋กœ๊ทธ์ธ ์‹œ์ž‘"์„ ์œ„ํ•œ useMutation ---
// ์ด ํ›…์€ API๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์•„. ์˜ค์ง ์นด์นด์˜ค ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ '์ด๋™'์‹œํ‚ค๋Š” ์•ก์…˜๋งŒ ์ฑ…์ž„์ ธ.
export const useKakaoLogin = () => {
return useMutation({
mutationFn: () => {
// ์‹ค์ œ๋กœ๋Š” Promise๋ฅผ ๋ฐ˜ํ™˜ํ•  ํ•„์š”๋Š” ์—†์ง€๋งŒ, mutationFn์€ Promise๋ฅผ ๊ธฐ๋Œ€ํ•ด.
window.location.href = `${BASE_URL}/oauth2/authorization/kakao
`;
Comment on lines +14 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

URL ๋ฌธ์ž์—ด ๋์— ๋ถˆํ•„์š”ํ•œ ๊ฐœํ–‰ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด URL์ด ๋น„์ •์ƒ์ ์œผ๋กœ ์ธ์ฝ”๋”ฉ(์˜ˆ: %0A ์ถ”๊ฐ€)๋˜์–ด ์˜ˆ๊ธฐ์น˜ ์•Š์€ ๋™์ž‘์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

      window.location.href = `${BASE_URL}/oauth2/authorization/kakao`;

return Promise.resolve();
},
onError: () => {
toast.error('์นด์นด์˜ค ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋Š” ๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
},
});
};

// --- 2. "์ฝœ๋ฐฑ ์ฒ˜๋ฆฌ"๋ฅผ ์œ„ํ•œ useQuery ---
export const KakaoCallbackQueryKey = {
callback: (ticket: string) => ['kakaoCallback', ticket],
};

export const useKakaoCallback = (ticket: string) => {
return useQuery({
queryKey: KakaoCallbackQueryKey.callback(ticket),
// code๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ์ด ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•ด.
queryFn: () => getTicketApi(ticket),
// ์ด ์ฟผ๋ฆฌ๋Š” ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋˜๋ฉด ๋˜๋ฏ€๋กœ, ์žฌ์‹œ๋„๋‚˜ ์บ์‹œ ๊ด€๋ จ ์˜ต์…˜์„ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ์–ด.
retry: 0,
staleTime: Infinity,
});
Comment on lines +30 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ticket ๊ฐ’์ด ์—†์„ ๋•Œ์—๋„ getTicketApi๊ฐ€ ํ˜ธ์ถœ๋˜์–ด ๋ถˆํ•„์š”ํ•œ API ์š”์ฒญ์ด ๋ฐœ์ƒํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. useQuery์˜ enabled ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜์—ฌ ticket์ด ์กด์žฌํ•  ๋•Œ๋งŒ ์ฟผ๋ฆฌ๊ฐ€ ์‹คํ–‰๋˜๋„๋ก ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์˜๋„์น˜ ์•Š์€ ์—๋Ÿฌ ๋ฐœ์ƒ์„ ๋ง‰๊ณ  ๋„คํŠธ์›Œํฌ ๋ฆฌ์†Œ์Šค๋ฅผ ์ ˆ์•ฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  return useQuery({
    queryKey: KakaoCallbackQueryKey.callback(ticket),
    // ticket์ด ์žˆ์„ ๋•Œ๋งŒ ์ด ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•ด.
    queryFn: () => getTicketApi(ticket),
    enabled: !!ticket,
    // ์ด ์ฟผ๋ฆฌ๋Š” ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋˜๋ฉด ๋˜๋ฏ€๋กœ, ์žฌ์‹œ๋„๋‚˜ ์บ์‹œ ๊ด€๋ จ ์˜ต์…˜์„ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ์–ด.
    retry: 0,
    staleTime: Infinity,
  });

};
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { BASE_URL, Button } from '@/shared';
import { useKakaoLogin } from '@/entities';
import { Button } from '@/shared';

import KakaoSymbol from '../../../_assets/kakao-symbol.webp';

export const KakaoLoginButton = () => {
const kakaoLogin = () => {
// OAuth ์„œ๋ฒ„๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋˜, ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URL์„ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •
const redirectUri = encodeURIComponent(`${window.location.origin}/oauth/redirect`);
window.location.href = `${BASE_URL}/oauth2/authorization/kakao?redirect_uri=${redirectUri}`;
};

const { mutate: startKakaoLogin, isPending } = useKakaoLogin();
return (
<Button
onClick={kakaoLogin}
onClick={() => startKakaoLogin()}
className='flex h-11 w-90 items-center gap-3 rounded-lg bg-kakao text-black hover:bg-kakao/80 active:bg-kakao/80'
>
<img src={KakaoSymbol} className='size-4' width={16} height={16} alt='kakao' />
<span className='text-sm font-semibold'>์นด์นด์˜ค ๊ณ„์ •์œผ๋กœ ๊ณ„์†ํ•˜๊ธฐ</span>
<span className='text-sm font-semibold'>
{isPending ? '์ด๋™์ค‘' : '์นด์นด์˜ค ๊ณ„์ •์œผ๋กœ ๊ณ„์†ํ•˜๊ธฐ'}
</span>
</Button>
);
};
103 changes: 16 additions & 87 deletions src/pages/oauth/OAuthRedirectPage.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,33 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

import { useGetAuthTicket } from '@/entities/auth';
import { toast } from 'sonner';

import { ROUTER_PATH } from '@/shared';
import { useKakaoCallback } from '@/entities';
import { ROUTER_PATH, authStorage } from '@/shared';

export default function OAuthRedirectPage() {
const [searchParams] = useSearchParams();

const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);

// URL์—์„œ ticket ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”์ถœ
const ticket = searchParams.get('ticket');
const oauthError = searchParams.get('error');
const errorDescription = searchParams.get('error_description');

useEffect(() => {
if (oauthError) {
setError(errorDescription || `OAuth ์ธ์ฆ ์˜ค๋ฅ˜: ${oauthError}`);
} else if (!ticket) {
setError('์ธ์ฆ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.');
}
}, [oauthError, errorDescription, ticket]);

// ticket์ด ์žˆ๊ณ  OAuth ์—๋Ÿฌ๊ฐ€ ์—†์„ ๋•Œ๋งŒ API ํ˜ธ์ถœ
const shouldCallApi = !!ticket && !oauthError;
const {
data: authData,
error: apiError,
isLoading,
} = useGetAuthTicket(shouldCallApi ? ticket : '');

// API ์—๋Ÿฌ ์ฒ˜๋ฆฌ
useEffect(() => {
if (apiError) {
console.error('API ํ˜ธ์ถœ ์ค‘ ์˜ค๋ฅ˜:', apiError);
setError('์ธ์ฆ ๊ฒ€์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.');
}
}, [apiError]);
const { data, isSuccess, isError } = useKakaoCallback(ticket ?? '');

// ์„ฑ๊ณต ์‹œ ์ฒ˜๋ฆฌ
useEffect(() => {
if (authData && !isLoading && !apiError && shouldCallApi) {
console.log('OAuth ์ธ์ฆ ์„ฑ๊ณต!', authData);

// refreshToken์„ localStorage์— ์ €์žฅ
localStorage.setItem('refreshToken', authData.refreshToken);
if (isSuccess && data) {
authStorage.refreshToken.set(data.refreshToken);

// ๋ฉ”์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™
navigate(ROUTER_PATH.MAIN, { replace: true });
navigate(ROUTER_PATH.ROOT);
}
}, [authData, isLoading, apiError, navigate, shouldCallApi]);

if (isLoading && shouldCallApi) {
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='text-center'>
<div className='mb-4'>
<div className='mx-auto h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent'></div>
</div>
<p className='text-lg font-medium text-gray-700'>์ธ์ฆ ์ฒ˜๋ฆฌ ์ค‘...</p>
<p className='mt-2 text-sm text-gray-500'>์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”.</p>
</div>
</div>
);
}

if (error) {
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='text-center'>
<div className='mb-4'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100'>
<svg
className='h-6 w-6 text-red-600'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</div>
</div>
<h2 className='mb-2 text-xl font-semibold text-gray-900'>์ธ์ฆ ์‹คํŒจ</h2>
<p className='mb-4 text-gray-600'>{error}</p>
<button
onClick={() => navigate(ROUTER_PATH.LOGIN)}
className='rounded-lg bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600'
>
๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋Œ์•„๊ฐ€๊ธฐ
</button>
</div>
</div>
);
}
if (isError) {
// ์‹คํŒจ ์‹œ, ์—๋Ÿฌ ์ฒ˜๋ฆฌ ํ›„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™
toast.error('์นด์นด์˜ค ๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
navigate('/login');
}
}, [isSuccess, isError, data, navigate]);
Comment on lines 18 to 30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useEffect ๋‚ด์˜ ๋‘ if ๋ฌธ์€ ์ƒํ˜ธ ๋ฐฐํƒ€์ ์ธ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๋ฏ€๋กœ if-else if ๊ตฌ์กฐ๋กœ ๋ณ€๊ฒฝํ•˜๋ฉด ์ฝ”๋“œ์˜ ๊ฐ€๋…์„ฑ๊ณผ ์˜๋„๋ฅผ ๋” ๋ช…ํ™•ํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

  useEffect(() => {
    if (isSuccess && data) {
      authStorage.refreshToken.set(data.refreshToken);

      navigate(ROUTER_PATH.ROOT);
    } else if (isError) {
      // ์‹คํŒจ ์‹œ, ์—๋Ÿฌ ์ฒ˜๋ฆฌ ํ›„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™
      toast.error('์นด์นด์˜ค ๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.');
      navigate('/login');
    }
  }, [isSuccess, isError, data, navigate]);


return null;
return <div>๋กœ๊ทธ์ธ ์ค‘์ž…๋‹ˆ๋‹ค...</div>;
}
Comment on lines 9 to 33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ํ˜„์žฌ ๊ตฌํ˜„์€ OAuth ์ธ์ฆ ์‹คํŒจ ์‹œ ์นด์นด์˜ค์—์„œ error์™€ error_description ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ๊ฒฝ์šฐ๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด ticket์ด ์—†์–ด ๋ฐœ์ƒํ•˜๋Š” API ์˜ค๋ฅ˜์— ์˜์กดํ•˜๊ฒŒ ๋˜์–ด, ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ถ€์ •ํ™•ํ•œ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด์—ฌ์ฃผ๊ฑฐ๋‚˜ ์ž ์žฌ์ ์ธ ๋ฒ„๊ทธ๋กœ ์ด์–ด์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

searchParams์—์„œ error ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ง์ ‘ ํ™•์ธํ•˜์—ฌ ๋” ๊ฒฌ๊ณ ํ•˜๊ฒŒ ์˜ค๋ฅ˜๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

31 changes: 31 additions & 0 deletions src/shared/utils/auth-storage/auth-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type AuthStorageKey = {
accessToken: string;
refreshToken: string;
};

const initStorage = <T extends keyof AuthStorageKey>(key: T, storage: Storage) => {
const storageKey = `${key}`;

const get = (): AuthStorageKey[T] => {
const value = storage.getItem(storageKey);

return JSON.parse(value as string);
};
Comment on lines +9 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

get ํ•จ์ˆ˜๋Š” localStorage์— ํ•ด๋‹น ํ‚ค๊ฐ€ ์—†์„ ๋•Œ storage.getItem(storageKey)๊ฐ€ null์„ ๋ฐ˜ํ™˜ํ•˜์—ฌ JSON.parse(null)์ด ํ˜ธ์ถœ๋˜๋ฉด์„œ ๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ์ด๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์ค‘๋‹จ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋Š” ์‹ฌ๊ฐํ•œ ๋ฒ„๊ทธ์ž…๋‹ˆ๋‹ค.
๊ฐ’์ด null์ธ ๊ฒฝ์šฐ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ , JSON.parse๋ฅผ try-catch ๋ธ”๋ก์œผ๋กœ ๊ฐ์‹ธ ์•ˆ์ •์„ฑ์„ ๋†’์ด๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ, ๋ฐ˜ํ™˜ ํƒ€์ž…์„ AuthStorageKey[T] | null๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ํ˜ธ์ถœํ•˜๋Š” ์ชฝ์—์„œ ๊ฐ’์ด ์—†์„ ์ˆ˜ ์žˆ๋Š” ์ƒํ™ฉ์„ ์ธ์ง€ํ•˜๊ณ  ์ฒ˜๋ฆฌํ•˜๋„๋ก ๊ฐ•์ œํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

  const get = (): AuthStorageKey[T] | null => {
    const value = storage.getItem(storageKey);

    if (value === null) {
      return null;
    }

    try {
      return JSON.parse(value);
    } catch (e) {
      console.error('Failed to parse auth storage value:', e);
      return null;
    }
  };


const set = (value: AuthStorageKey[T]) => {
if (value === undefined || value === null) {
return storage.removeItem(storageKey);
}

const stringifiedValue = JSON.stringify(value);

storage.setItem(storageKey, stringifiedValue);
};
Comment on lines +15 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

set ํ•จ์ˆ˜์˜ value ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž…์€ AuthStorageKey[T] (์ฆ‰, string)์œผ๋กœ ์ง€์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด value === undefined || value === null ์กฐ๊ฑด๋ฌธ์€ TypeScript์˜ strictNullChecks ์˜ต์…˜์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ ํ•ญ์ƒ false๊ฐ€ ๋˜์–ด ์‚ฌ์‹ค์ƒ ๋„๋‹ฌํ•  ์ˆ˜ ์—†๋Š” ์ฝ”๋“œ๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.
storage.removeItem์„ ํ˜ธ์ถœํ•˜๋Š” ์˜๋„๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๋ ค๋ฉด, value์˜ ํƒ€์ž…์„ AuthStorageKey[T] | null | undefined๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ null ๋˜๋Š” undefined๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ํ—ˆ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

  const set = (value: AuthStorageKey[T] | null | undefined) => {
    if (value === undefined || value === null) {
      return storage.removeItem(storageKey);
    }

    const stringifiedValue = JSON.stringify(value);

    storage.setItem(storageKey, stringifiedValue);
  };


return { get, set };
};

export const authStorage = {
accessToken: initStorage('accessToken', localStorage),
refreshToken: initStorage('refreshToken', localStorage),
};
1 change: 1 addition & 0 deletions src/shared/utils/auth-storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './auth-storage';
1 change: 1 addition & 0 deletions src/shared/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './scroll-to-top';
export * from './class-value';
export * from './image-cache';
export * from './chart';
export * from './auth-storage';
Loading