diff --git a/jobdri/src/app/oauth2/redirect/OAuthRedirectClient.tsx b/jobdri/src/app/oauth2/redirect/OAuthRedirectClient.tsx new file mode 100644 index 0000000..7d08ec8 --- /dev/null +++ b/jobdri/src/app/oauth2/redirect/OAuthRedirectClient.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ROUTES } from "@/constants/routes"; +import { getEmailFromAccessToken, saveAuthTokens } from "@/lib/auth"; + +export default function OAuthRedirectClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + const accessToken = searchParams.get("accessToken"); + const refreshToken = searchParams.get("refreshToken"); + const error = searchParams.get("error"); + const errorMessage = searchParams.get("message"); + const message = + accessToken && refreshToken + ? "Google 로그인 처리 중입니다." + : errorMessage || + (error ? "Google 로그인에 실패했습니다." : "로그인 정보를 확인할 수 없습니다."); + + useEffect(() => { + if (accessToken && refreshToken) { + saveAuthTokens( + { accessToken, refreshToken }, + searchParams.get("email") || + getEmailFromAccessToken(accessToken) || + undefined, + ); + router.replace(ROUTES.APPLY); + return; + } + + const timerId = window.setTimeout(() => { + router.replace(ROUTES.LOGIN); + }, 2000); + + return () => { + window.clearTimeout(timerId); + }; + }, [accessToken, refreshToken, router, searchParams]); + + return ( +
+
+

+ JobDri +

+

+ {message} +

+
+
+ ); +} diff --git a/jobdri/src/app/oauth2/redirect/page.tsx b/jobdri/src/app/oauth2/redirect/page.tsx new file mode 100644 index 0000000..75fe707 --- /dev/null +++ b/jobdri/src/app/oauth2/redirect/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; +import OAuthRedirectClient from "./OAuthRedirectClient"; + +function OAuthRedirectFallback() { + return ( +
+
+

+ JobDri +

+

+ Google 로그인 처리 중입니다. +

+
+
+ ); +} + +export default function OAuthRedirectPage() { + return ( + }> + + + ); +} diff --git a/jobdri/src/components/common/lnb/Lnb.tsx b/jobdri/src/components/common/lnb/Lnb.tsx index 26a0c21..f5eccb0 100644 --- a/jobdri/src/components/common/lnb/Lnb.tsx +++ b/jobdri/src/components/common/lnb/Lnb.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useState, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { createPortal } from "react-dom"; import Icon, { type IconType } from "@/components/common/icons/Icon"; import { ModalNotice } from "@/components/common/modal"; +import { AUTH_STORAGE_KEYS, getStoredAuthEmail } from "@/lib/auth"; type LnbItemKey = "experience" | "apply"; @@ -34,12 +35,47 @@ const navItems: LnbNavItem[] = [ const navItemBaseClassName = "flex h-9 items-center gap-2 rounded-cta-l p-3 text-sub14-med"; +const defaultEmail = "jobdri@gmail.com"; + +function subscribeToStoredEmail(onStoreChange: () => void) { + const handleStorage = (event: StorageEvent) => { + if (event.key === AUTH_STORAGE_KEYS.userEmail) { + onStoreChange(); + } + }; + + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener("storage", handleStorage); + }; +} + +function getStoredEmailSnapshot() { + return getStoredAuthEmail() ?? ""; +} + +function getServerStoredEmailSnapshot() { + return ""; +} + +function getEmailInitial(email: string) { + return email.trim().charAt(0).toUpperCase() || "J"; +} + export default function Lnb({ initialActiveItem, - email = "jobdri@gmail.com", + email, creditCount = 32, }: LnbProps) { const router = useRouter(); + const storedEmail = useSyncExternalStore( + subscribeToStoredEmail, + getStoredEmailSnapshot, + getServerStoredEmailSnapshot, + ); + const displayEmail = (email ?? storedEmail) || defaultEmail; + const emailInitial = getEmailInitial(displayEmail); const [isFold, setIsFold] = useState(false); const [showComingSoonModal, setShowComingSoonModal] = useState(false); const [activeItem, setActiveItem] = useState( @@ -151,11 +187,11 @@ export default function Lnb({ }`} >
- J + {emailInitial}
{!isFold && ( - {email} + {displayEmail} )} diff --git a/jobdri/src/components/login/EmailLoginScreen.tsx b/jobdri/src/components/login/EmailLoginScreen.tsx index aa22af3..44a2d45 100644 --- a/jobdri/src/components/login/EmailLoginScreen.tsx +++ b/jobdri/src/components/login/EmailLoginScreen.tsx @@ -9,6 +9,7 @@ import type { SetStateAction, } from "react"; import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import clsx from "clsx"; import { Button, @@ -17,6 +18,16 @@ import { } from "@/components/common/buttons"; import { InputMain, InputSingleLine } from "@/components/common/input"; import { Tooltip } from "@/components/common/tooltip"; +import { ROUTES } from "@/constants/routes"; +import { + AuthApiError, + confirmEmailVerification, + getGoogleAuthorizationUrl, + loginWithEmail, + saveAuthTokens, + sendEmailVerification, + signupWithEmail, +} from "@/lib/auth"; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const passwordPattern = /^(?=.*[A-Za-z])(?=.*\d).{8,20}$/; @@ -26,14 +37,16 @@ const passwordMaxLengthMessage = "비밀번호는 최대 20자까지만 가능 const passwordMismatchMessage = "비밀번호가 일치하지 않습니다"; const verificationCodeLength = 6; const initialVerificationCode = Array(verificationCodeLength).fill(""); -const verificationErrorMessage = "인증번호를 다시 확인해주세요."; -const mockVerificationSuccessCode = "123456"; +const defaultVerificationErrorMessage = "인증번호를 다시 확인해주세요."; +const loginValidationErrorMessage = "이메일과 비밀번호를 확인해주세요"; export default function EmailLoginScreen() { + const router = useRouter(); const [authMode, setAuthMode] = useState< "login" | "signup" | "verify" | "success" >("login"); const [showCreditTooltip, setShowCreditTooltip] = useState(true); + const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); @@ -41,7 +54,20 @@ export default function EmailLoginScreen() { initialVerificationCode, ); const [hasVerificationError, setHasVerificationError] = useState(false); + const [verificationErrorMessage, setVerificationErrorMessage] = useState( + defaultVerificationErrorMessage, + ); const [loginError, setLoginError] = useState(false); + const [loginErrorMessage, setLoginErrorMessage] = useState( + loginValidationErrorMessage, + ); + const [isLoginSubmitting, setIsLoginSubmitting] = useState(false); + const [signupErrorMessage, setSignupErrorMessage] = useState(""); + const [isSignupSubmitting, setIsSignupSubmitting] = useState(false); + const [isVerificationSubmitting, setIsVerificationSubmitting] = + useState(false); + const [isResendingVerificationCode, setIsResendingVerificationCode] = + useState(false); const verificationInputRefs = useRef>([]); const isLoginReady = email.length > 0 && password.length > 0; @@ -60,6 +86,7 @@ export default function EmailLoginScreen() { const hasPasswordMismatchError = passwordConfirm.length > 0 && passwordConfirm !== password; const isSignupReady = + name.trim().length > 0 && emailPattern.test(email) && passwordPattern.test(password) && passwordConfirm === password; @@ -91,6 +118,9 @@ export default function EmailLoginScreen() { ) => { setter(value); setLoginError(false); + setLoginErrorMessage(loginValidationErrorMessage); + setSignupErrorMessage(""); + setVerificationErrorMessage(defaultVerificationErrorMessage); hideCreditTooltip(); }; @@ -200,57 +230,117 @@ export default function EmailLoginScreen() { fillVerificationCode(index, event.clipboardData.getData("text")); }; - const handleLoginSubmit = (event: FormEvent) => { + const handleLoginSubmit = async (event: FormEvent) => { event.preventDefault(); if ( + isLoginSubmitting || !isLoginReady || !emailPattern.test(email) || !passwordPattern.test(password) ) { setLoginError(true); + setLoginErrorMessage(loginValidationErrorMessage); return; } - setLoginError(true); + setLoginError(false); + setIsLoginSubmitting(true); + hideCreditTooltip(); + + try { + const tokens = await loginWithEmail({ email, password }); + saveAuthTokens(tokens, email); + router.push(ROUTES.APPLY); + } catch (error) { + setLoginError(true); + setLoginErrorMessage( + error instanceof AuthApiError + ? error.errorDetail || error.message + : "로그인 중 문제가 발생했습니다.", + ); + } finally { + setIsLoginSubmitting(false); + } + }; + + const handleGoogleLogin = () => { + window.location.assign(getGoogleAuthorizationUrl()); }; - const handleSignupSubmit = (event: FormEvent) => { + const handleSignupSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!isSignupReady || !emailPattern.test(email)) { + if (isSignupSubmitting || !isSignupReady || !emailPattern.test(email)) { return; } - setVerificationCode([...initialVerificationCode]); - setHasVerificationError(false); - setAuthMode("verify"); + setSignupErrorMessage(""); + setIsSignupSubmitting(true); + + try { + await sendEmailVerification({ email }); + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(false); + setVerificationErrorMessage(defaultVerificationErrorMessage); + setAuthMode("verify"); + } catch (error) { + setSignupErrorMessage( + error instanceof AuthApiError + ? error.errorDetail || error.message + : "인증번호 발송 중 문제가 발생했습니다.", + ); + } finally { + setIsSignupSubmitting(false); + } }; - const handleVerificationSubmit = (event: FormEvent) => { + const handleVerificationSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!isVerificationReady) { + if (isVerificationSubmitting || !isVerificationReady) { return; } - if (verificationCode.join("") === mockVerificationSuccessCode) { + setIsVerificationSubmitting(true); + + try { + await confirmEmailVerification({ + email, + code: verificationCode.join(""), + }); + await signupWithEmail({ + name: name.trim(), + email, + password, + }); setAuthMode("success"); setVerificationCode([...initialVerificationCode]); setHasVerificationError(false); - return; - } + setVerificationErrorMessage(defaultVerificationErrorMessage); + } catch (error) { + setVerificationCode([...initialVerificationCode]); + setHasVerificationError(true); + setVerificationErrorMessage( + error instanceof AuthApiError + ? error.errorDetail || error.message + : defaultVerificationErrorMessage, + ); - setVerificationCode([...initialVerificationCode]); - setHasVerificationError(true); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } finally { + setIsVerificationSubmitting(false); } }; const handleModeChange = (mode: "login" | "signup") => { setAuthMode(mode); setLoginError(false); + setSignupErrorMessage(""); + setVerificationErrorMessage(defaultVerificationErrorMessage); + setName(""); setEmail(""); setPassword(""); setPasswordConfirm(""); @@ -267,11 +357,31 @@ export default function EmailLoginScreen() { setAuthMode("signup"); setVerificationCode([...initialVerificationCode]); setHasVerificationError(false); + setVerificationErrorMessage(defaultVerificationErrorMessage); hideCreditTooltip(); }; - const handleResendVerificationCode = () => { + const handleResendVerificationCode = async () => { + if (isResendingVerificationCode) { + return; + } + resetVerificationToInitial(); + setVerificationErrorMessage(defaultVerificationErrorMessage); + setIsResendingVerificationCode(true); + + try { + await sendEmailVerification({ email }); + } catch (error) { + setHasVerificationError(true); + setVerificationErrorMessage( + error instanceof AuthApiError + ? error.errorDetail || error.message + : "인증번호 재발송 중 문제가 발생했습니다.", + ); + } finally { + setIsResendingVerificationCode(false); + } }; return ( @@ -304,7 +414,10 @@ export default function EmailLoginScreen() { verificationCode={verificationCode} verificationInputRefs={verificationInputRefs} hasVerificationError={hasVerificationError} + verificationErrorMessage={verificationErrorMessage} isVerificationReady={isVerificationReady} + isSubmitting={isVerificationSubmitting} + isResending={isResendingVerificationCode} onBack={handleBackToSignup} onCodeChange={handleVerificationCodeChange} onCodeFocus={handleVerificationCodeFocus} @@ -345,6 +458,7 @@ export default function EmailLoginScreen() { autoComplete="email" placeholder="내용을 입력해주세요." value={email} + disabled={isLoginSubmitting} hasError={loginError} className="self-stretch" onChange={(value) => @@ -358,23 +472,20 @@ export default function EmailLoginScreen() { autoComplete="current-password" placeholder="내용을 입력해주세요." value={password} - error={ - loginError - ? "이메일과 비밀번호를 확인해주세요" - : undefined - } + disabled={isLoginSubmitting} + error={loginError ? loginErrorMessage : undefined} className="self-stretch" onChange={handlePasswordChange} />