-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 카카오 OAuth 로그인 플로우 완전 구현 및 인증 토큰 관리 시스템 구축 #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e3d39e0
3ddb954
51c1705
7cfa50b
8c8f326
286a15b
45add72
08ecb15
dfdf6d4
4b7fe0c
075fc19
9f2f7c7
d879a9b
649615e
de4628e
d927d1c
170e603
f4671cf
07b2541
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,17 @@ | ||
| import { fetchInstance } from '@/shared'; | ||
|
|
||
| export const GET_TICKET_API_PATH = '/api/auth/ticket'; | ||
| export const GET_TICKET_API_PATH = () => '/auth/ticket'; | ||
|
|
||
| interface GetTicketApiResponse { | ||
| refreshToken: string; | ||
| } | ||
|
|
||
| export const getTicketApi = async (ticket: string): Promise<GetTicketApiResponse> => { | ||
| const response = await fetchInstance.get<GetTicketApiResponse>(GET_TICKET_API_PATH, { | ||
| const response = await fetchInstance.get<GetTicketApiResponse>(GET_TICKET_API_PATH(), { | ||
| params: { | ||
| ticket, | ||
| }, | ||
| }); | ||
|
|
||
| return response.data; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from './get-ticket.api'; | ||
| export * from './refresh-token.api'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { fetchInstance } from '@/shared'; | ||
|
|
||
| export const REFRESH_TOKEN_API_PATH = '/auth/refresh'; | ||
|
|
||
| interface RefreshTokenApiRequest { | ||
| refreshToken: string; | ||
| } | ||
|
|
||
| interface RefreshTokenApiResponse { | ||
| accessToken: string; | ||
| } | ||
|
|
||
| export const refreshTokenApi = async ({ | ||
| refreshToken, | ||
| }: RefreshTokenApiRequest): Promise<RefreshTokenApiResponse> => { | ||
| const response = await fetchInstance.post<RefreshTokenApiResponse>(REFRESH_TOKEN_API_PATH, { | ||
| refreshToken, | ||
| }); | ||
|
|
||
| return response.data; | ||
| }; |
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| export * from './getAuthTicket'; | ||
| export * from './useGetAuthTicket'; | ||
| export * from './useKakaoLogin'; | ||
| export * from './useGetRefreshToken'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
|
|
||
| import { GET_TICKET_API_PATH, getTicketApi } from '../apis'; | ||
|
|
||
| export const GetAuthTicketQueryKey = [GET_TICKET_API_PATH()]; | ||
|
|
||
| export const useGetAuthTicket = (ticket: string) => { | ||
| return useQuery({ | ||
| queryKey: [GetAuthTicketQueryKey, ticket], | ||
| queryFn: () => getTicketApi(ticket), | ||
| enabled: !!ticket && ticket.trim().length > 0, | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { useQuery } from '@tanstack/react-query'; | ||
|
|
||
| import { authStorage } from '@/shared'; | ||
|
|
||
| import { REFRESH_TOKEN_API_PATH, refreshTokenApi } from '../apis'; | ||
|
|
||
| export const GetRefreshTokenQueryKey = [REFRESH_TOKEN_API_PATH]; | ||
|
|
||
| export const useGetRefreshToken = () => { | ||
| return useQuery({ | ||
| queryKey: [GetRefreshTokenQueryKey], | ||
| queryFn: () => refreshTokenApi({ refreshToken: authStorage.refreshToken.get() }), | ||
| enabled: !!authStorage.refreshToken.get(), | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { fetchInstance } from '@/shared'; | ||
|
|
||
| export const SIZE_PRICE_API_PATH = '/graph/sizePriceIndex'; | ||
|
|
||
| interface SizePriceApiResponse { | ||
| month: string; | ||
| sizeband: string; | ||
| baseindex: number; | ||
| changerate: number; | ||
| } | ||
|
|
||
| export const sizePriceApi = async (): Promise<SizePriceApiResponse> => { | ||
| const response = await fetchInstance.get<SizePriceApiResponse>(SIZE_PRICE_API_PATH); | ||
|
|
||
| return response.data; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,19 @@ | ||
| import { useKakaoLogin } from '@/entities'; | ||
| import { Button } from '@/shared'; | ||
| import { BASE_URL, Button } from '@/shared'; | ||
|
|
||
| import KakaoSymbol from '../../../_assets/kakao-symbol.webp'; | ||
|
|
||
| export const KakaoLoginButton = () => { | ||
| const { mutate: startKakaoLogin, isPending } = useKakaoLogin(); | ||
| const startKakaoLogin = () => { | ||
| window.location.href = `${BASE_URL}/oauth2/authorization/kakao`; | ||
| }; | ||
|
|
||
| return ( | ||
| <Button | ||
| 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'> | ||
| {isPending ? '이동중' : '카카오 계정으로 계속하기'} | ||
| </span> | ||
| <span className='text-sm font-semibold'>카카오 계정으로 계속하기</span> | ||
| </Button> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { type RefObject } from 'react'; | ||
| import { useForm } from 'react-hook-form'; | ||
|
|
||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||
|
|
@@ -17,7 +18,11 @@ import { | |
| RentTypeField, | ||
| } from '../../common'; | ||
|
|
||
| export const DiagnosticForm = () => { | ||
| type Props = { | ||
| scrollToRiskAnalysis: RefObject<HTMLDivElement>; | ||
| }; | ||
|
|
||
| export const DiagnosticForm = ({ scrollToRiskAnalysis }: Props) => { | ||
| const { setDiagnosisData } = useHouseData(); | ||
|
|
||
| const form = useForm<SearchAddressType>({ | ||
|
|
@@ -40,6 +45,20 @@ export const DiagnosticForm = () => { | |
| }), | ||
| onSuccess: (data) => { | ||
| setDiagnosisData(data); | ||
|
|
||
| // 진단 완료 후 위험도 분석 섹션으로 스크롤 | ||
| setTimeout(() => { | ||
| if (scrollToRiskAnalysis.current) { | ||
| const element = scrollToRiskAnalysis.current; | ||
| const elementTop = element.offsetTop; | ||
| const offset = 80; | ||
|
|
||
| window.scrollTo({ | ||
| top: elementTop - offset, | ||
| behavior: 'smooth', | ||
| }); | ||
| } | ||
| }, 100); | ||
|
Comment on lines
+50
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }, | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,3 +1,5 @@ | ||||||
| import { useRef } from 'react'; | ||||||
|
|
||||||
| import { | ||||||
| AlternativeSection, | ||||||
| ChartSection, | ||||||
|
|
@@ -9,13 +11,15 @@ import { | |||||
| } from '@/features'; | ||||||
|
|
||||||
| export default function MainPage() { | ||||||
| const riskAnalysisRef = useRef<HTMLDivElement>(null); | ||||||
|
|
||||||
| return ( | ||||||
| <div className='flex min-h-screen w-full flex-col'> | ||||||
| <div className='flex w-full gap-4'> | ||||||
| <InputSection /> | ||||||
| <InputSection scrollToRiskAnalysis={riskAnalysisRef as React.RefObject<HTMLDivElement>} /> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기서
Suggested change
|
||||||
| <MapSection /> | ||||||
| </div> | ||||||
| <RiskAnalysisSummarySection /> | ||||||
| <RiskAnalysisSummarySection ref={riskAnalysisRef} /> | ||||||
| <ChartSection /> | ||||||
| <LandlordReliabilitySection /> | ||||||
| <LandlordPropertySection /> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,33 +1,54 @@ | ||
| import { useEffect } from 'react'; | ||
| import { useNavigate, useSearchParams } from 'react-router-dom'; | ||
| import { useLocation, useNavigate } from 'react-router-dom'; | ||
|
|
||
| import { toast } from 'sonner'; | ||
|
|
||
| import { useKakaoCallback } from '@/entities'; | ||
| import { ROUTER_PATH, authStorage } from '@/shared'; | ||
| import { useGetAuthTicket, useGetRefreshToken } from '@/entities'; | ||
| import { ROUTER_PATH, Spinner, authStorage } from '@/shared'; | ||
|
|
||
| export default function OAuthRedirectPage() { | ||
| const [searchParams] = useSearchParams(); | ||
|
|
||
| const location = useLocation(); | ||
| const navigate = useNavigate(); | ||
|
|
||
| const ticket = searchParams.get('ticket'); | ||
| const ticket = new URLSearchParams(location.search).get('ticket'); | ||
|
|
||
| const { data, isSuccess, isError } = useKakaoCallback(ticket ?? ''); | ||
| const { data, isLoading } = useGetAuthTicket(ticket ?? ''); | ||
|
|
||
| useEffect(() => { | ||
| if (isSuccess && data) { | ||
| authStorage.refreshToken.set(data.refreshToken); | ||
|
|
||
| navigate(ROUTER_PATH.ROOT); | ||
| if (data) { | ||
| const refreshToken = data.refreshToken; | ||
| authStorage.refreshToken.set(refreshToken); | ||
| navigate(ROUTER_PATH.ROOT, { replace: true }); | ||
| } | ||
|
|
||
| if (isError) { | ||
| // 실패 시, 에러 처리 후 로그인 페이지로 이동 | ||
| toast.error('카카오 로그인에 실패했습니다.'); | ||
| navigate(ROUTER_PATH.LOGIN); | ||
| } | ||
| }, [isSuccess, isError, data, navigate]); | ||
|
|
||
| return <div>로그인 중입니다...</div>; | ||
| }, [data, navigate]); | ||
|
Comment on lines
15
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 구현은 이 문제를 해결하려면 예시: // 기존 useEffect 수정
useEffect(() => {
if (data) {
authStorage.refreshToken.set(data.refreshToken);
}
}, [data]);
// refreshTokenData를 받아오는 로직은 그대로 둡니다.
const { data: refreshTokenData, isLoading: isRefreshTokenLoading } = useGetRefreshToken();
// 새로운 useEffect 추가
useEffect(() => {
if (refreshTokenData) {
authStorage.accessToken.set(refreshTokenData.accessToken);
navigate(ROUTER_PATH.ROOT, { replace: true });
}
}, [refreshTokenData, navigate]);위 코드처럼 로직을 분리하면 인증 흐름이 순차적으로 안전하게 실행됩니다. |
||
|
|
||
| const { data: refreshTokenData, isLoading: isRefreshTokenLoading } = useGetRefreshToken(); | ||
|
|
||
| if (!ticket) { | ||
| return <div>로그인을 다시 진행해주세요.</div>; | ||
| } | ||
|
|
||
| if (isLoading || isRefreshTokenLoading) { | ||
| return <Spinner />; | ||
| } | ||
|
|
||
| if (!data || !refreshTokenData) { | ||
| return <Spinner />; | ||
| } | ||
|
|
||
| if (refreshTokenData) { | ||
| authStorage.accessToken.set(refreshTokenData.accessToken); | ||
| } | ||
|
|
||
| return ( | ||
| <div className='flex min-h-screen items-center justify-center'> | ||
| <div className='text-center'> | ||
| <div className='mb-4 text-2xl font-semibold'> | ||
| {isLoading ? '로그인 처리 중...' : '로그인 중입니다...'} | ||
| </div> | ||
|
|
||
| <div className='text-gray-500'> | ||
| {isLoading ? '서버와 통신 중입니다.' : '잠시만 기다려주세요.'} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sizePriceApi 함수의 반환 타입이 단일
SizePriceApiResponse객체에 대한 Promise로 지정되어 있습니다. 하지만 그래프에 사용되는sizePriceIndex와 같은 API 엔드포인트는 일반적으로 데이터 포인트의 배열을 반환합니다. 만약 API가 배열을 반환한다면, 타입 불일치로 인해 런타임 오류가 발생할 수 있습니다. 실제 API 응답을 확인하고, 필요한 경우 반환 타입을Promise<SizePriceApiResponse[]>로 수정해주세요.