-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] 구글 소셜 로그인, accessToken 재발급 처리 및 온보딩 API 연동 #152
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
5eccca3
a84ddbf
c04aaa9
64f9ce6
97576c6
5b0080d
44263ea
4bb860d
7da9f11
46ea500
c087d48
4f1df0e
c261863
c39acc0
39aaecc
1aa0548
cafecd7
169a67e
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,19 +1,77 @@ | ||
| import axios from "axios"; | ||
| import axios, { AxiosError } from "axios"; | ||
|
|
||
| import type { BaseResponseAuthReissueResponse } from "@/api/generated/models"; | ||
|
|
||
| import { parseApiError } from "@/api/error/api-error"; | ||
| import { ROUTES } from "@/constants/routes"; | ||
| import { useAuthStore } from "@/stores/auth/useAuthStore"; | ||
|
|
||
| const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL; | ||
| if (!baseURL) | ||
| throw new Error("NEXT_PUBLIC_API_BASE_URL이 설정되지 않았습니다."); | ||
|
|
||
| export const instance = axios.create({ | ||
| baseURL, | ||
| withCredentials: true, | ||
| }); | ||
|
|
||
| instance.interceptors.request.use((config) => { | ||
| const accessToken = useAuthStore.getState().accessToken; | ||
| if (accessToken) { | ||
| config.headers.Authorization = `Bearer ${accessToken}`; | ||
| } | ||
| return config; | ||
| }); | ||
|
|
||
| const REISSUE_URL = "/api/v1/auth/reissue"; | ||
| const retriedRequests = new WeakSet<object>(); | ||
| let reissuePromise: Promise<string | undefined> | null = null; | ||
|
|
||
| const reissueAccessToken = () => { | ||
| if (!reissuePromise) { | ||
| reissuePromise = instance | ||
| .post<BaseResponseAuthReissueResponse>(REISSUE_URL) | ||
| .then(({ data }) => { | ||
| const reissueData = data.data; | ||
| if (reissueData) | ||
| useAuthStore.getState().setAccessToken(reissueData.accessToken); | ||
| return reissueData?.accessToken; | ||
| }) | ||
| .catch((error) => { | ||
| useAuthStore.getState().clearAccessToken(); | ||
| throw error; | ||
| }) | ||
| .finally(() => { | ||
| reissuePromise = null; | ||
| }); | ||
| } | ||
| return reissuePromise; | ||
| }; | ||
|
|
||
| instance.interceptors.response.use( | ||
| (response) => response, | ||
| (error) => { | ||
| // TODO: 추후 인증 방식 확정 후 status(401 등) 기반 사이드 이펙트(리다이렉트 등) 추가 | ||
| async (error: AxiosError) => { | ||
| const originalRequest = error.config; | ||
| const isReissueRequest = originalRequest?.url === REISSUE_URL; | ||
|
|
||
| if ( | ||
| error.response?.status === 401 && | ||
| originalRequest && | ||
| !isReissueRequest && | ||
| !retriedRequests.has(originalRequest) | ||
| ) { | ||
| retriedRequests.add(originalRequest); | ||
| try { | ||
| const newAccessToken = await reissueAccessToken(); | ||
| if (newAccessToken) { | ||
| originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; | ||
| return instance(originalRequest); | ||
| } | ||
| window.location.href = ROUTES.LOGIN; | ||
| } catch { | ||
| window.location.href = ROUTES.LOGIN; | ||
| } | ||
| } | ||
| return Promise.reject(parseApiError(error)); | ||
| }, | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| "use client"; | ||
|
|
||
| import { useQueryClient } from "@tanstack/react-query"; | ||
| import { useSearchParams } from "next/navigation"; | ||
| import { useEffect, useRef } from "react"; | ||
|
|
||
| import { useToken } from "@/api/generated/endpoints/auth/auth"; | ||
| import { getGetMyProfileQueryKey } from "@/api/generated/endpoints/user/user"; | ||
| import { ROUTES } from "@/constants/routes"; | ||
| import { useRouter } from "@/i18n/navigation"; | ||
| import { useAuthStore } from "@/stores/auth/useAuthStore"; | ||
|
|
||
| export const OauthCallbackContainer = () => { | ||
| const code = useSearchParams().get("code"); | ||
| const router = useRouter(); | ||
| const queryClient = useQueryClient(); | ||
| const setAccessToken = useAuthStore((state) => state.setAccessToken); | ||
| const { mutate } = useToken(); | ||
| const hasRequested = useRef(false); | ||
|
|
||
| useEffect(() => { | ||
| if (!code) { | ||
| router.replace(ROUTES.LOGIN); | ||
| return; | ||
| } | ||
| if (hasRequested.current) return; | ||
| hasRequested.current = true; | ||
|
|
||
| mutate( | ||
| { data: { code } }, | ||
| { | ||
| onSuccess: ({ data }) => { | ||
| if (!data?.accessToken) { | ||
| router.replace(ROUTES.LOGIN); | ||
| return; | ||
| } | ||
| setAccessToken(data.accessToken); | ||
| queryClient.setQueryData(getGetMyProfileQueryKey(), data.user); | ||
| router.replace(data.isNewUser ? ROUTES.ONBOARDING : ROUTES.HOME); | ||
| }, | ||
| onError: () => { | ||
| router.replace(ROUTES.LOGIN); | ||
| }, | ||
| }, | ||
| ); | ||
| }, [code, mutate, queryClient, router, setAccessToken]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return null; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { OauthCallbackContainer } from "@/app/[locale]/oauth/callback/_containers/OauthCallbackContainer"; | ||
| import { AsyncBoundary } from "@/components/boundary/AsyncBoundary"; | ||
|
|
||
|
|
||
| export default function OauthCallbackPage() { | ||
| return ( | ||
| <AsyncBoundary> | ||
| <OauthCallbackContainer /> | ||
| </AsyncBoundary> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,21 @@ | ||
| "use client"; | ||
|
|
||
| import { useFunnel } from "@use-funnel/browser"; | ||
| import { useTranslations } from "next-intl"; | ||
| import { useState } from "react"; | ||
|
|
||
| import { useCompleteOnboarding } from "@/api/generated/endpoints/onboarding/onboarding"; | ||
| import { OnboardingRequestLanguage } from "@/api/generated/models"; | ||
| import { OnboardingStepButton } from "@/app/[locale]/onboarding/_components/OnboardingStepButton"; | ||
| import { CalendarConnectStepContainer } from "@/app/[locale]/onboarding/_containers/CalendarConnectStepContainer"; | ||
| import { LanguageStepContainer } from "@/app/[locale]/onboarding/_containers/LanguageStepContainer"; | ||
| import { LifePatternStepContainer } from "@/app/[locale]/onboarding/_containers/LifePatternStepContainer"; | ||
| import { TimePredictionStepContainer } from "@/app/[locale]/onboarding/_containers/TimePredictionStepContainer"; | ||
| import { OnboardingFunnelSteps } from "@/app/[locale]/onboarding/_types/onboarding-funnel"; | ||
| import { LottiePlayer } from "@/components/lottie/LottiePlayer"; | ||
| import { AnimatedToast } from "@/components/toast/AnimatedToast"; | ||
| import { ROUTES } from "@/constants/routes"; | ||
| import { useRouter } from "@/i18n/navigation"; | ||
|
|
||
| const STEP_NUMBER: Record<keyof OnboardingFunnelSteps, 1 | 2 | 3 | 4> = { | ||
| Language: 1, | ||
|
|
@@ -18,18 +24,33 @@ const STEP_NUMBER: Record<keyof OnboardingFunnelSteps, 1 | 2 | 3 | 4> = { | |
| CalendarConnect: 4, | ||
| }; | ||
|
|
||
| const ONBOARDING_LANGUAGE_MAP: Record<"ko" | "en", OnboardingRequestLanguage> = | ||
| { | ||
| ko: OnboardingRequestLanguage.KO, | ||
| en: OnboardingRequestLanguage.EN, | ||
| }; | ||
|
|
||
| export const OnboardingFunnelContainer = () => { | ||
| const t = useTranslations("Toast"); | ||
| const router = useRouter(); | ||
| const [answers, setAnswers] = useState< | ||
| Partial<OnboardingFunnelSteps["CalendarConnect"]> | ||
| >({}); | ||
| const [isErrorToastOpen, setIsErrorToastOpen] = useState(false); | ||
|
|
||
| const funnel = useFunnel<OnboardingFunnelSteps>({ | ||
| id: "onboarding", | ||
| initial: { step: "Language", context: {} }, | ||
| }); | ||
| const { mutate: completeOnboarding, isPending } = useCompleteOnboarding(); | ||
|
|
||
| return ( | ||
| <section className="flex min-h-screen items-center justify-center gap-10 bg-white px-8 lg:gap-16 xl:gap-36 2xl:gap-[225px]"> | ||
| <AnimatedToast | ||
| isOpen={isErrorToastOpen} | ||
| onClose={() => setIsErrorToastOpen(false)} | ||
| message={t("onboardingSubmitFailed")} | ||
| /> | ||
| <LottiePlayer | ||
| src="/lottie/onboarding.json" | ||
| className="hidden shrink-0 lg:block lg:size-[350px] xl:size-[430px] 2xl:size-[500px]" | ||
|
|
@@ -100,6 +121,7 @@ export const OnboardingFunnelContainer = () => { | |
| )} | ||
| CalendarConnect={({ history }) => ( | ||
| <CalendarConnectStepContainer | ||
| isPending={isPending} | ||
| onPrev={() => history.back()} | ||
| onStart={() => { | ||
| if ( | ||
|
|
@@ -109,7 +131,26 @@ export const OnboardingFunnelContainer = () => { | |
| !answers.bedTime | ||
| ) | ||
| return; | ||
| // TODO: 실제 제출 API 연동 + 완료 후 answers.language로 locale 리다이렉트 | ||
| completeOnboarding( | ||
| { | ||
| data: { | ||
| language: ONBOARDING_LANGUAGE_MAP[answers.language], | ||
| predictionAccuracy: answers.predictionAccuracy, | ||
| wakeUpTime: answers.wakeUpTime, | ||
| bedTime: answers.bedTime, | ||
| }, | ||
| }, | ||
| { | ||
| onSuccess: () => { | ||
| router.replace(ROUTES.HOME, { | ||
| locale: answers.language, | ||
| }); | ||
| }, | ||
| onError: () => { | ||
| setIsErrorToastOpen(true); | ||
| }, | ||
| }, | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+136
to
+153
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. 온보딩 API가 아직 검증되지 않은 상태라고 To Reviewers에 명시하셨는데, 정작 실패 핸들러가 없어요. 실패하면 사용자가 CalendarConnect 단계에서 아무 피드백 없이 멈춰요. 간단하게 .
Contributor
Author
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. 아주 좋은 의견입니다. 온보딩 API에 onError 추가하고 Home 넘어갈 때 errorFallback 추가하겠습니다.
Contributor
Author
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. Next.js는 error.tsx를 통한 라우트 단위 에러 처리만 지원하고, 현재 구조에서는 Server Component → Client Component로 fallback 함수를 전달할 수 없어서 Home의 errorFallback 다시 제거했습니다 ~ |
||
| }} | ||
| /> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| import { OnboardingFunnelContainer } from "@/app/[locale]/onboarding/_containers/OnboardingFunnelContainer"; | ||
| import { AuthGuardProvider } from "@/providers/auth/AuthGuardProvider"; | ||
|
|
||
| export default function OnboardingPage() { | ||
| return <OnboardingFunnelContainer />; | ||
| return ( | ||
| <AuthGuardProvider> | ||
| <OnboardingFunnelContainer /> | ||
| </AuthGuardProvider> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.