-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/#53 카카오 소셜 로그인 프로세스 구현 #66
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
90f6081
9da927c
6eb9a7c
6a2c710
335066f
f198d0b
2ddcbb5
492da69
3246332
7cc4743
a8d0270
23e595d
106e705
96b3746
54ece42
3face67
f328020
36b307e
6bb148c
b121561
4e7c789
132040c
39e629e
eeb68da
c9a62e3
b390ae2
5666f85
766cb66
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 |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { API_PATH, CLIENT_PATH } from '@/_constants/path' | ||
| import { setCookie, deleteCookie } from 'cookies-next' | ||
| import axios from 'axios' | ||
|
|
||
| const CLIENT_URL = process.env.NEXT_PUBLIC_CLIENT_URL || '' | ||
| const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID || '' | ||
|
|
||
| export const getKakaoInga = async () => { | ||
| const isAndroid = /Android/.test(navigator.userAgent) | ||
| // const isiOS = /(iPhone|iPad|iPod)/.test(navigator.userAgent); | ||
| try { | ||
| if (isAndroid) { | ||
| window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${CLIENT_URL + CLIENT_PATH.LOGIN_SUCCESS}&response_type=code` | ||
| } else { | ||
| await window.Kakao.Auth.authorize({ | ||
| redirectUri: CLIENT_URL + CLIENT_PATH.LOGIN_SUCCESS, | ||
| }) | ||
| } | ||
| } catch (error) { | ||
| console.log(error) | ||
| } | ||
| } | ||
|
|
||
| export const getToken = async (): Promise<{ | ||
| tokenType: string | ||
| accessToken: string | ||
| accessTokenExpiresIn: number | ||
| }> => { | ||
| try { | ||
| const res = await axios.post( | ||
| process.env.NEXT_PUBLIC_API_URL + API_PATH.AUTH.TOKEN, | ||
| {}, | ||
| { | ||
| withCredentials: true, | ||
| }, | ||
| ) | ||
|
|
||
| const { data } = res | ||
| const { accessToken, accessTokenExpiresIn } = data.data | ||
| const expireDate = new Date(Date.now() + accessTokenExpiresIn) | ||
|
|
||
| setCookie('accessToken', accessToken, { | ||
| expires: expireDate, | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'lax', | ||
| path: '/', | ||
| }) | ||
|
|
||
| return data.data | ||
| } catch (error) { | ||
| console.error('토큰 재발급 실패(세션 만료):', error) | ||
| deleteCookie('accessToken') | ||
|
|
||
| if (typeof window !== 'undefined') { | ||
| window.location.href = CLIENT_PATH.LOGIN | ||
| } | ||
|
|
||
| throw error | ||
| } | ||
| } | ||
|
Comment on lines
+24
to
+60
Contributor
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. getToken 반환 타입과 실제 반환값이 일치하지 않습니다 (Critical). Line 24-28의 타입 선언은 평탄화된 객체 이로 인해 다음 중 하나를 적용하여 수정하세요: 방법 1: 반환 타입과 일치하도록 평탄화된 객체 반환 (권장) setCookie('accessToken', accessToken, {
expires: expireDate,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
- return res.data
+ return data.data
} catch (error) {방법 2: 타입 선언을 실제 반환값과 일치시키기 -export const getToken = async (): Promise<{
- tokenType: string
- accessToken: string
- accessTokenExpiresIn: number
-}> => {
+export const getToken = async (): Promise<{
+ data: {
+ tokenType: string
+ accessToken: string
+ accessTokenExpiresIn: number
+ }
+}> => {그리고 - const { accessToken: newAccessToken } = await getToken()
+ const { data: { accessToken: newAccessToken } } = await getToken()방법 1을 권장합니다. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,57 @@ | ||
| import axios from 'axios' | ||
| import axios, { | ||
| AxiosError, | ||
| AxiosResponse, | ||
| InternalAxiosRequestConfig, | ||
| } from 'axios' | ||
| import { getToken } from '@/_apis/services/login' | ||
| import { getCookie } from '@/_utils/getCookie' | ||
|
|
||
| const axiosInstance = axios.create({ | ||
| baseURL: process.env.NEXT_PUBLIC_API_URL, | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| withCredentials: true, | ||
| }) | ||
|
|
||
| axiosInstance.interceptors.request.use( | ||
| async (config) => { | ||
| const token = await getCookie('accessToken') | ||
|
|
||
| if (token) { | ||
| config.headers.Authorization = `Bearer ${token}` | ||
| } | ||
|
|
||
| return config | ||
| }, | ||
| (error) => Promise.reject(error), | ||
| ) | ||
|
|
||
| axiosInstance.interceptors.response.use( | ||
| (response: AxiosResponse) => response.data, | ||
| async (error: AxiosError): Promise<AxiosResponse | AxiosError> => { | ||
| const originalRequest = error.config as InternalAxiosRequestConfig & { | ||
| _retry?: boolean | ||
| } | ||
| if (error.response?.status === 401 && !originalRequest._retry) { | ||
| originalRequest._retry = true | ||
|
|
||
| try { | ||
| const { accessToken: newAccessToken } = await getToken() | ||
|
|
||
| if (originalRequest.headers) { | ||
| originalRequest.headers.Authorization = newAccessToken | ||
| ? `Bearer ${newAccessToken}` | ||
| : '' | ||
| } | ||
|
|
||
| return await axiosInstance(originalRequest) | ||
| } catch (refreshError) { | ||
| return Promise.reject(refreshError) | ||
| } | ||
| } | ||
| return Promise.reject(error) | ||
| }, | ||
| ) | ||
|
Comment on lines
+30
to
+55
Contributor
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. 🧩 Analysis chain401 처리부의 getToken 반환 구조 및 타입 불일치 가능성 401 응답 처리 로직에서 다음과 같이 const { accessToken: newAccessToken } = await getToken()하지만 제공된
또한 이 인터셉터는 성공 시 권장 사항:
I'll verify the review comment by examining the Length of output: 27 🏁 Script executed: #!/bin/bash
# Find imports and usages of axiosInstance to understand expected response types
fd -t f "\.(ts|tsx)$" | xargs rg -n "axiosInstance" -B 2 -A 5 --type=ts --type=tsx | head -150Length of output: 27 🏁 Script executed: #!/bin/bash
# Check the full axios interceptor setup and response type declarations
cat apps/web/app/_lib/axiosInstance.tsLength of output: 1667 🏁 Script executed: #!/bin/bash
# Search for getToken function and setCookie usage to verify the implementation
fd -t f "\.(ts|tsx)$" | xargs rg -n "setCookie.*accessToken|getToken" -B 3 -A 10 --type=ts --type=tsx | head -150Length of output: 27 Length of output: 233 🏁 Script executed: #!/bin/bash
# Search for getToken in all TypeScript files using correct rg syntax
rg -n "export.*function.*getToken|export.*const.*getToken" -A 15 --type tsLength of output: 1017 🏁 Script executed: #!/bin/bash
# Find all files in _apis directory
fd --path "*/_apis/*" -t fLength of output: 215 Length of output: 1582 🏁 Script executed: #!/bin/bash
# Search for usages of axiosInstance in the codebase to check how responses are typed
rg -n "axiosInstance\." --type ts -A 3 -B 1 | head -100Length of output: 5814 Length of output: 191 🏁 Script executed: #!/bin/bash
# Check if there are any test files or type validation for getToken
rg -n "getToken" apps/web/app --type ts | grep -E "test|spec|mock" | head -20Length of output: 0 🏁 Script executed: #!/bin/bash
# Look at how axiosInstance response is typed when used
rg -n "await axiosInstance" --type ts -B 2 -A 5 | head -80Length of output: 5030 getToken 반환 타입과 response interceptor 타입 선언 불일치 - 검증 완료 검증 결과 리뷰의 우려사항이 확인되었습니다:
필수 수정 사항:
🤖 Prompt for AI Agents |
||
|
|
||
| export default axiosInstance | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,8 @@ | ||||||||||||||||||||||||||||||||||||
| import axios from 'axios' | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const axiosInstanceV2 = axios.create({ | ||||||||||||||||||||||||||||||||||||
| baseURL: process.env.NEXT_PUBLIC_API_URL_V2, | ||||||||||||||||||||||||||||||||||||
| withCredentials: true, | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+6
Contributor
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. 환경 변수가 정의되지 않은 경우를 처리하세요.
다음과 같이 환경 변수 검증을 추가하세요: import axios from 'axios'
+ const apiUrlV2 = process.env.NEXT_PUBLIC_API_URL_V2
+ if (!apiUrlV2) {
+ throw new Error('NEXT_PUBLIC_API_URL_V2 environment variable is not defined')
+ }
+
const axiosInstanceV2 = axios.create({
- baseURL: process.env.NEXT_PUBLIC_API_URL_V2,
+ baseURL: apiUrlV2,
withCredentials: true,
})
export default axiosInstanceV2📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export default axiosInstanceV2 | ||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { getCookie as ClientGetCookie } from 'cookies-next' | ||
|
|
||
| /** | ||
| * 서버(Next.js App Router)와 클라이언트(Browser) 환경 모두에서 작동하는 하이브리드 쿠키 조회 함수입니다. | ||
| * | ||
| * @remarks | ||
| * 이 함수는 실행 환경을 자동으로 감지하여 적절한 방식으로 쿠키를 가져옵니다. | ||
| * - **Server Environment**: `next/headers`의 `cookies()`를 동적으로 임포트하여 조회합니다. (클라이언트 번들링 제외) | ||
| * - **Client Environment**: `cookies-next` 라이브러리를 사용하여 브라우저 쿠키를 조회합니다. | ||
| * - `next/headers`의 비동기 특성에 맞춰 인터페이스 통일을 위해 항상 `Promise`를 반환합니다. | ||
| * | ||
| * @param name - 조회할 쿠키의 키(Key) 이름 (예: 'accessToken') | ||
| * @returns 쿠키의 값(`string`)을 반환하며, 쿠키가 존재하지 않을 경우 `undefined`를 반환합니다. | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * // async 함수 내부에서 사용 | ||
| * const token = await getCookie('accessToken'); | ||
| * | ||
| * if (token) { | ||
| * console.log('Token exists:', token); | ||
| * } | ||
| * ``` | ||
| */ | ||
| export const getCookie = async (name: string): Promise<string | undefined> => { | ||
| // 서버 환경 (Next.js Server Component / Server Action / Route Handler) | ||
| if (typeof window === 'undefined') { | ||
| const { cookies } = await import('next/headers') | ||
| const cookieStore = await cookies() | ||
| return cookieStore.get(name)?.value | ||
| } | ||
|
|
||
| // 클라이언트 환경 (Browser) | ||
| return ClientGetCookie(name) as string | undefined | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { getCookie } from './getCookie' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| interface Window { | ||
| Kakao: { | ||
| init: (appKey: string) => void | ||
| isInitialized: () => boolean | ||
| Auth: { | ||
| authorize: (params: Record<string, unknown>) => Promise<void> | ||
| } | ||
| [key: string]: unknown | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect } from 'react' | ||
| import { Icon } from '@repo/ui/components/Icon' | ||
| import { Text } from '@repo/ui/components/Text' | ||
| import { getKakaoInga } from '@/_apis/services/login' | ||
|
|
||
| export const LoginButton = () => { | ||
| useEffect(() => { | ||
| if (!window.Kakao) return | ||
| if (!window.Kakao.isInitialized()) { | ||
| window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JS_KEY || '') | ||
| console.log('Kakao initialized') | ||
| } else { | ||
| console.log('Kakao already initialized') | ||
| } | ||
| }, []) | ||
|
|
||
| return ( | ||
| <button | ||
| onClick={getKakaoInga} | ||
| className={'relative rounded-lg bg-[#FEE500] p-3 text-center'} | ||
| > | ||
| <Icon type={'kakaoLogo'} className={'absolute left-[20px]'} /> | ||
| <Text>카카오 로그인</Text> | ||
| </button> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // import { NextRequest, NextResponse } from 'next/server' | ||
| // import axiosInstanceV2 from '@/_lib/axiosInstanceV2' | ||
| // import { API_PATH, CLIENT_PATH } from '@/_constants/path' | ||
| // | ||
| // export const GET = async (request: NextRequest) => { | ||
| // try { | ||
| // const searchParams = request.nextUrl.searchParams | ||
| // const code = searchParams.get('code') || '' | ||
| // const redirectUri = | ||
| // (process.env.NEXT_PUBLIC_CLIENT_URL || '') + CLIENT_PATH.LOGIN_SUCCESS | ||
| // | ||
| // // 백엔드로 OAuth 인증 요청 | ||
| // const response = await axiosInstanceV2.get( | ||
| // API_PATH.AUTH.AUTHORIZE(code, redirectUri), | ||
| // ) | ||
| // | ||
| // // 브라우저로 리다이렉트 응답 준비 | ||
| // const nextRes = NextResponse.redirect( | ||
| // new URL(CLIENT_PATH.MAIN, request.url), | ||
| // ) | ||
| // | ||
| // const accessToken = response?.data?.data | ||
| // if (accessToken) { | ||
| // nextRes.cookies.set('accessToken', accessToken, { | ||
| // path: '/', | ||
| // httpOnly: false, | ||
| // }) | ||
| // } | ||
| // | ||
| // const backendCookies = response.headers['set-cookie'] | ||
| // if (backendCookies) { | ||
| // backendCookies.forEach((cookie) => { | ||
| // nextRes.headers.append('Set-Cookie', cookie) | ||
| // }) | ||
| // } | ||
| // | ||
| // return nextRes | ||
| // } catch (error) { | ||
| // console.log(error) | ||
| // return NextResponse.json( | ||
| // { message: 'Internal Server Error' }, | ||
| // { status: 500 }, | ||
| // ) | ||
| // } | ||
| // } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect } from 'react' | ||
| import { Spinner } from '@heroui/react' | ||
| import { useSearchParams, useRouter } from 'next/navigation' | ||
| import { setCookie } from 'cookies-next' | ||
| import axiosInstanceV2 from '@/_lib/axiosInstanceV2' | ||
| import { API_PATH, CLIENT_PATH } from '@/_constants/path' | ||
|
|
||
| const Page = () => { | ||
| const { replace } = useRouter() | ||
| const searchParams = useSearchParams() | ||
| const code = searchParams.get('code') || '' | ||
| const clientUrl = process.env.NEXT_PUBLIC_CLIENT_URL || '' | ||
| const redirectUri = clientUrl + CLIENT_PATH.LOGIN_SUCCESS | ||
|
|
||
| useEffect(() => { | ||
| if (!code || !clientUrl) { | ||
| console.error('Authorization code is missing') | ||
| replace(`${CLIENT_PATH.LOGIN}?error=code-missing`) | ||
| return | ||
| } | ||
|
|
||
| ;(async () => { | ||
| try { | ||
| const response = await axiosInstanceV2.get( | ||
| API_PATH.AUTH.AUTHORIZE(code, redirectUri), | ||
| ) | ||
|
|
||
| const accessToken = response?.data?.data | ||
| if (accessToken) { | ||
| setCookie('accessToken', accessToken, { | ||
| secure: process.env.NODE_ENV === 'production', | ||
| sameSite: 'lax', | ||
| path: '/', | ||
| }) | ||
| replace(CLIENT_PATH.MAIN) | ||
| } else { | ||
| console.error('Access token missing in response') | ||
| replace(`${CLIENT_PATH.LOGIN}?error=token-missing`) | ||
| } | ||
| } catch (error) { | ||
| console.error('Login process failed:', error) | ||
| replace(`${CLIENT_PATH.LOGIN}?error=auth-failed`) | ||
| } | ||
| })() | ||
| }, [clientUrl, code, redirectUri, replace]) | ||
|
|
||
| return <Spinner className={'m-auto'} /> | ||
| } | ||
|
|
||
| export default Page |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { Icon } from '@repo/ui/components/Icon' | ||
| import { Column } from '@repo/ui/components/Layout' | ||
| import { Text } from '@repo/ui/components/Text' | ||
| import { LoginButton } from './components/LoginButton' | ||
|
|
||
| const LoginPage = () => { | ||
| return ( | ||
| <Column className={'h-full px-5 py-10'}> | ||
| <Column className={'my-auto items-center pb-5'}> | ||
| <Icon type={'logo'} size={150} className={'mb-2'} /> | ||
| <Text fontSize={'2xl'}>공주대학교</Text> | ||
| <Text fontSize={'2xl'} fontWeight={'bold'}> | ||
| 맛집 | ||
| </Text> | ||
| </Column> | ||
| <LoginButton /> | ||
| </Column> | ||
| ) | ||
| } | ||
|
|
||
| export default LoginPage |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,11 @@ | ||
| 'use client' | ||
|
|
||
| import dynamic from 'next/dynamic' | ||
| import { Spinner } from '@heroui/react' | ||
|
|
||
| const MapComponent = dynamic(() => import('./MapComponent'), { | ||
| ssr: false, | ||
| loading: () => <div>지도 로딩 중...</div>, | ||
| loading: () => <Spinner className={'m-auto'} />, | ||
| }) | ||
|
|
||
| export const MapContainer = () => <MapComponent /> |
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.
🛠️ Refactor suggestion | 🟠 Major
타입 안전성 및 에러 처리 개선 필요.
이 함수에는 몇 가지 개선이 필요한 부분이 있습니다:
window.Kakao에 대한 타입 정의가 없어 타입 안전성이 부족합니다.타입 안전성을 위해 전역 타입 정의를 추가하세요 (예:
types/kakao.d.ts):에러 처리를 개선하세요:
} catch (error) { - console.log(error) + console.error('Kakao login failed:', error) + alert('로그인에 실패했습니다. 다시 시도해주세요.') + throw error }🤖 Prompt for AI Agents