diff --git a/apps/kokomen-webview/src/domains/auth/api/index.ts b/apps/kokomen-webview/src/domains/auth/api/index.ts index 89b5e090..b4ec24fd 100644 --- a/apps/kokomen-webview/src/domains/auth/api/index.ts +++ b/apps/kokomen-webview/src/domains/auth/api/index.ts @@ -16,6 +16,16 @@ const postAuthorizationCode = async ( }); }; +const postGoogleAuthorizationCode = async ( + code: string, + redirectUri: string +): AxiosPromise => { + return authServerInstance.post(`/auth/google-login`, { + code, + redirect_uri: redirectUri + }); +}; + const getUserInfo = async (): AxiosPromise => { return authServerInstance.get(`/members/me/profile`); }; @@ -42,6 +52,7 @@ const deleteUser = async (): AxiosPromise => { export { postAuthorizationCode, + postGoogleAuthorizationCode, getUserInfo, logout, updateUserProfile, diff --git a/apps/kokomen-webview/src/main.tsx b/apps/kokomen-webview/src/main.tsx index 73f7a8c3..68addc78 100644 --- a/apps/kokomen-webview/src/main.tsx +++ b/apps/kokomen-webview/src/main.tsx @@ -56,8 +56,8 @@ function AuthRouter() { return ; } -posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { - api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, +posthog.init(import.meta.env.VITE_POSTHOG_KEY, { + api_host: import.meta.env.VITE_POSTHOG_HOST, defaults: "2025-05-24" }); diff --git a/apps/kokomen-webview/src/routeTree.gen.ts b/apps/kokomen-webview/src/routeTree.gen.ts index 1a84e019..580f5171 100644 --- a/apps/kokomen-webview/src/routeTree.gen.ts +++ b/apps/kokomen-webview/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as LoginProfileRouteImport } from './routes/login/profile' import { Route as LoginCallbackRouteImport } from './routes/login/callback' import { Route as InterviewsInterviewIdIndexRouteImport } from './routes/interviews/$interviewId/index' import { Route as MembersInterviewsInterviewIdRouteImport } from './routes/members/interviews.$interviewId' +import { Route as LoginGoogleCallbackRouteImport } from './routes/login/google.callback' import { Route as InterviewsInterviewIdResultRouteImport } from './routes/interviews/$interviewId/result' const IndexRoute = IndexRouteImport.update({ @@ -79,6 +80,11 @@ const MembersInterviewsInterviewIdRoute = path: '/members/interviews/$interviewId', getParentRoute: () => rootRouteImport, } as any) +const LoginGoogleCallbackRoute = LoginGoogleCallbackRouteImport.update({ + id: '/login/google/callback', + path: '/login/google/callback', + getParentRoute: () => rootRouteImport, +} as any) const InterviewsInterviewIdResultRoute = InterviewsInterviewIdResultRouteImport.update({ id: '/interviews/$interviewId/result', @@ -97,6 +103,7 @@ export interface FileRoutesByFullPath { '/interviews': typeof InterviewsIndexRoute '/login': typeof LoginIndexRoute '/interviews/$interviewId/result': typeof InterviewsInterviewIdResultRoute + '/login/google/callback': typeof LoginGoogleCallbackRoute '/members/interviews/$interviewId': typeof MembersInterviewsInterviewIdRoute '/interviews/$interviewId': typeof InterviewsInterviewIdIndexRoute } @@ -111,6 +118,7 @@ export interface FileRoutesByTo { '/interviews': typeof InterviewsIndexRoute '/login': typeof LoginIndexRoute '/interviews/$interviewId/result': typeof InterviewsInterviewIdResultRoute + '/login/google/callback': typeof LoginGoogleCallbackRoute '/members/interviews/$interviewId': typeof MembersInterviewsInterviewIdRoute '/interviews/$interviewId': typeof InterviewsInterviewIdIndexRoute } @@ -126,6 +134,7 @@ export interface FileRoutesById { '/interviews/': typeof InterviewsIndexRoute '/login/': typeof LoginIndexRoute '/interviews/$interviewId/result': typeof InterviewsInterviewIdResultRoute + '/login/google/callback': typeof LoginGoogleCallbackRoute '/members/interviews/$interviewId': typeof MembersInterviewsInterviewIdRoute '/interviews/$interviewId/': typeof InterviewsInterviewIdIndexRoute } @@ -142,6 +151,7 @@ export interface FileRouteTypes { | '/interviews' | '/login' | '/interviews/$interviewId/result' + | '/login/google/callback' | '/members/interviews/$interviewId' | '/interviews/$interviewId' fileRoutesByTo: FileRoutesByTo @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/interviews' | '/login' | '/interviews/$interviewId/result' + | '/login/google/callback' | '/members/interviews/$interviewId' | '/interviews/$interviewId' id: @@ -170,6 +181,7 @@ export interface FileRouteTypes { | '/interviews/' | '/login/' | '/interviews/$interviewId/result' + | '/login/google/callback' | '/members/interviews/$interviewId' | '/interviews/$interviewId/' fileRoutesById: FileRoutesById @@ -185,6 +197,7 @@ export interface RootRouteChildren { InterviewsIndexRoute: typeof InterviewsIndexRoute LoginIndexRoute: typeof LoginIndexRoute InterviewsInterviewIdResultRoute: typeof InterviewsInterviewIdResultRoute + LoginGoogleCallbackRoute: typeof LoginGoogleCallbackRoute MembersInterviewsInterviewIdRoute: typeof MembersInterviewsInterviewIdRoute InterviewsInterviewIdIndexRoute: typeof InterviewsInterviewIdIndexRoute } @@ -268,6 +281,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MembersInterviewsInterviewIdRouteImport parentRoute: typeof rootRouteImport } + '/login/google/callback': { + id: '/login/google/callback' + path: '/login/google/callback' + fullPath: '/login/google/callback' + preLoaderRoute: typeof LoginGoogleCallbackRouteImport + parentRoute: typeof rootRouteImport + } '/interviews/$interviewId/result': { id: '/interviews/$interviewId/result' path: '/interviews/$interviewId/result' @@ -289,6 +309,7 @@ const rootRouteChildren: RootRouteChildren = { InterviewsIndexRoute: InterviewsIndexRoute, LoginIndexRoute: LoginIndexRoute, InterviewsInterviewIdResultRoute: InterviewsInterviewIdResultRoute, + LoginGoogleCallbackRoute: LoginGoogleCallbackRoute, MembersInterviewsInterviewIdRoute: MembersInterviewsInterviewIdRoute, InterviewsInterviewIdIndexRoute: InterviewsInterviewIdIndexRoute, } diff --git a/apps/kokomen-webview/src/routes/login/google.callback.tsx b/apps/kokomen-webview/src/routes/login/google.callback.tsx new file mode 100644 index 00000000..c54f9056 --- /dev/null +++ b/apps/kokomen-webview/src/routes/login/google.callback.tsx @@ -0,0 +1,212 @@ +import { postGoogleAuthorizationCode } from "@/domains/auth/api"; +import { useAuthStore } from "@/store"; +import { useMutation } from "@tanstack/react-query"; +import { + createFileRoute, + Link, + useRouter, + useSearch +} from "@tanstack/react-router"; +import React, { useEffect } from "react"; + +// eslint-disable-next-line @rushstack/typedef-var +export const Route = createFileRoute("/login/google/callback")({ + component: RouteComponent +}); + +const ROOT_URI: string = "/interviews"; +function RouteComponent(): React.ReactNode { + const router = useRouter(); + const { code, state } = useSearch({ + from: "/login/google/callback", + select: (search) => search as { code?: string; state?: string } + }); + + const authMutation = useMutation({ + mutationFn: ({ + code, + redirectUri + }: { + code: string; + redirectUri: string; + }) => postGoogleAuthorizationCode(code, redirectUri), + + onSuccess: ({ data }) => { + useAuthStore.getState().setAuth(data); + const redirectTo = state && state !== "/" ? state : ROOT_URI; + if (!data.profile_completed) { + router.navigate({ to: `/login/profile?state=${redirectTo}` }); + return; + } + router.navigate({ to: redirectTo, replace: true }); + }, + + onError: (error) => { + console.error("로그인 실패:", error); + }, + retry: 1 + }); + + useEffect(() => { + if ( + !code || + authMutation.isPending || + authMutation.error || + authMutation.isSuccess + ) + return; + + const redirectUri = `${import.meta.env.VITE_BASE_URL}/login/google/callback`; + + authMutation.mutate({ + code: code as string, + redirectUri + }); + }, [code, state, authMutation]); + + // 로딩 상태 + if (authMutation.isPending) { + return ( +
+
+
+
+ + + + +
+

+ 로그인 처리 중... +

+

구글 로그인을 완료하고 있습니다

+
+
+
+ ); + } + + // 성공 상태 + if (authMutation.isSuccess) { + return ( +
+
+
+
+ + + +
+

+ 로그인 완료! +

+

+ 구글 로그인이 성공적으로 완료되었습니다 +

+
+
+ 페이지가 곧 이동됩니다... +
+ + 지금 이동하기 + +
+
+
+
+ ); + } + + // 에러 상태 + if (authMutation.isError) { + return ( +
+
+
+
+ + + +
+

+ 로그인 실패 +

+

+ 로그인 처리 중 문제가 발생했습니다. 다시 시도해주세요. +

+
+ + + 로그인 페이지로 돌아가기 + + + 홈으로 이동 + +
+
+
+
+ ); + } + + return null; +} diff --git a/apps/kokomen-webview/src/routes/login/index.tsx b/apps/kokomen-webview/src/routes/login/index.tsx index 9e972c0d..224b3e9b 100644 --- a/apps/kokomen-webview/src/routes/login/index.tsx +++ b/apps/kokomen-webview/src/routes/login/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { createFileRoute, Link, useSearch } from "@tanstack/react-router"; import React from "react"; // eslint-disable-next-line @rushstack/typedef-var @@ -13,15 +13,19 @@ function RouteComponent(): React.ReactNode { }); const redirectTo = `&state=${query.redirectTo ?? "/"}`; const redirectUri = `${import.meta.env.VITE_BASE_URL}/login/callback${redirectTo}`; + const googleRedirectUri = `${import.meta.env.VITE_BASE_URL}/login/google/callback${redirectTo}`; return ( <> -
+
{/* 로고 및 헤더 */} -
-

로그인

+
+ 꼬꼬면 로고 +

+ 꼬꼬면에 로그인 +

- 로그인하고 AI 모의 면접 서비스를 체험해보세요! + 면접 연습을 시작하려면 로그인이 필요합니다

@@ -30,49 +34,71 @@ function RouteComponent(): React.ReactNode { href={`${import.meta.env.VITE_API_BASE_URL}/auth/kakao-login?redirectUri=${redirectUri}`} className="w-full flex items-center justify-center px-4 py-3 border border-transparent rounded-xl text-base font-medium text-black bg-[#FEE500] hover:bg-[#FFEB3B] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-400 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg" > -
- +
+ - 카카오로 시작하기 + 카카오로 시작하기 +
+ + +
+ + + + + + + + 구글로 시작하기

- 로그인 시 꼬꼬면의{" "} - 서비스 이용약관 - - 과{" "} - + 과 + 개인정보 처리방침 - + 에 동의하게 됩니다.

- - {/* 하단 정보 */} -
-

- 처음 방문하시나요?{" "} - - 카카오로 간편하게 가입하세요 - -

-