diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/login/login-form.tsx | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/login/login-form.tsx')
| -rw-r--r-- | components/login/login-form.tsx | 814 |
1 files changed, 559 insertions, 255 deletions
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index 4f9fbb53..7af607b5 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -3,43 +3,66 @@ import { useState, useEffect } from "react"; import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" -import { SendIcon, Loader2, GlobeIcon, ChevronDownIcon, Ship, InfoIcon } from "lucide-react"; +import { Ship, InfoIcon, GlobeIcon, ChevronDownIcon, ArrowLeft } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu" import { useTranslation } from '@/i18n/client' import { useRouter, useParams, usePathname, useSearchParams } from 'next/navigation'; +import { signIn, getSession } from 'next-auth/react'; +import { buttonVariants } from "@/components/ui/button" +import Link from "next/link" +import Image from 'next/image'; +import { useFormState } from 'react-dom'; import { InputOTP, InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp" -import { signIn } from 'next-auth/react'; -import { sendOtpAction } from "@/lib/users/send-otp"; -import { verifyTokenAction } from "@/lib/users/verifyToken"; -import { buttonVariants } from "@/components/ui/button" -import Link from "next/link" -import Image from 'next/image'; // 추가: Image 컴포넌트 import +import { requestPasswordResetAction } from "@/lib/users/auth/partners-auth"; + +type LoginMethod = 'username' | 'sgips'; export function LoginForm({ className, ...props }: React.ComponentProps<"div">) { - const params = useParams() || {}; const pathname = usePathname() || ''; const router = useRouter(); const searchParams = useSearchParams(); - const token = searchParams?.get('token') || null; - const [showCredentialsForm, setShowCredentialsForm] = useState(false); - const lng = params.lng as string; const { t, i18n } = useTranslation(lng, 'login'); - const { toast } = useToast(); + // 상태 관리 + const [loginMethod, setLoginMethod] = useState<LoginMethod>('username'); + const [isLoading, setIsLoading] = useState(false); + const [showForgotPassword, setShowForgotPassword] = useState(false); + + // MFA 관련 상태 + const [showMfaForm, setShowMfaForm] = useState(false); + const [mfaToken, setMfaToken] = useState(''); + const [mfaUserId, setMfaUserId] = useState(''); + const [mfaUserEmail, setMfaUserEmail] = useState(''); + const [mfaCountdown, setMfaCountdown] = useState(0); + + // 일반 로그인 폼 데이터 + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + // S-Gips 로그인 폼 데이터 + const [sgipsUsername, setSgipsUsername] = useState(''); + const [sgipsPassword, setSgipsPassword] = useState(''); + + // 서버 액션 상태 + const [passwordResetState, passwordResetAction] = useFormState(requestPasswordResetAction, { + success: false, + error: undefined, + message: undefined, + }); + const handleChangeLanguage = (lang: string) => { const segments = pathname.split('/'); segments[1] = lang; @@ -48,50 +71,66 @@ export function LoginForm({ const currentLanguageText = i18n.language === 'ko' ? t('languages.korean') : t('languages.english'); - const [email, setEmail] = useState(''); - const [otpSent, setOtpSent] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [otp, setOtp] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const goToVendorRegistration = () => { router.push(`/${lng}/partners/repository`); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + // MFA 카운트다운 효과 + useEffect(() => { + if (mfaCountdown > 0) { + const timer = setTimeout(() => setMfaCountdown(mfaCountdown - 1), 1000); + return () => clearTimeout(timer); + } + }, [mfaCountdown]); + + // 서버 액션 결과 처리 + useEffect(() => { + if (passwordResetState.success && passwordResetState.message) { + toast({ + title: '재설정 링크 전송', + description: passwordResetState.message, + }); + setShowForgotPassword(false); + } else if (passwordResetState.error) { + toast({ + title: t('errorTitle'), + description: passwordResetState.error, + variant: 'destructive', + }); + } + }, [passwordResetState, toast, t]); + + // SMS 토큰 전송 + const handleSendSms = async () => { + if (!mfaUserId || mfaCountdown > 0) return; + setIsLoading(true); try { - const result = await sendOtpAction(email, lng); + // SMS 전송 API 호출 (실제 구현 필요) + const response = await fetch('/api/auth/send-sms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: mfaUserId }), + }); - if (result.success) { - setOtpSent(true); + if (response.ok) { + setMfaCountdown(60); // 60초 카운트다운 toast({ - title: t('otpSentTitle'), - description: t('otpSentMessage'), + title: 'SMS 전송 완료', + description: '인증번호를 전송했습니다.', }); } else { - // Handle specific error types - let errorMessage = t('defaultErrorMessage'); - - // You can handle different error types differently - if (result.error === 'userNotFound') { - errorMessage = t('userNotFoundMessage'); - } - toast({ title: t('errorTitle'), - description: result.message || errorMessage, + description: 'SMS 전송에 실패했습니다.', variant: 'destructive', }); } } catch (error) { - // This will catch network errors or other unexpected issues - console.error(error); + console.error('SMS send error:', error); toast({ title: t('errorTitle'), - description: t('networkErrorMessage'), + description: 'SMS 전송 중 오류가 발생했습니다.', variant: 'destructive', }); } finally { @@ -99,65 +138,75 @@ export function LoginForm({ } }; - async function handleOtpSubmit(e: React.FormEvent) { + // MFA 토큰 검증 + const handleMfaSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (!mfaToken || mfaToken.length !== 6) { + toast({ + title: t('errorTitle'), + description: '6자리 인증번호를 입력해주세요.', + variant: 'destructive', + }); + return; + } + setIsLoading(true); try { - // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { - email, - code: otp, - redirect: false, // 커스텀 처리 위해 redirect: false + // MFA 토큰 검증 API 호출 + const response = await fetch('/api/auth/verify-mfa', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: mfaUserId, + token: mfaToken + }), }); - if (result?.ok) { - // 토스트 메시지 표시 + if (response.ok) { toast({ - title: t('loginSuccess'), - description: t('youAreLoggedIn'), + title: '인증 완료', + description: '로그인이 완료되었습니다.', }); + // callbackUrl 처리 const callbackUrlParam = searchParams?.get('callbackUrl'); - if (callbackUrlParam) { - try { - // URL 객체로 파싱 - const callbackUrl = new URL(callbackUrlParam); - - // pathname + search만 사용 (호스트 제거) - const relativeUrl = callbackUrl.pathname + callbackUrl.search; - router.push(relativeUrl); - } catch (e) { - // 유효하지 않은 URL이면 그대로 사용 (이미 상대 경로일 수 있음) - router.push(callbackUrlParam); - } + try { + const callbackUrl = new URL(callbackUrlParam); + const relativeUrl = callbackUrl.pathname + callbackUrl.search; + router.push(relativeUrl); + } catch (e) { + router.push(callbackUrlParam); + } } else { - // callbackUrl이 없으면 기본 대시보드로 리다이렉트 - router.push(`/${lng}/partners/dashboard`); + router.push(`/${lng}/partners/dashboard`); } - } else { + const errorData = await response.json(); toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: errorData.message || '인증번호가 올바르지 않습니다.', variant: 'destructive', }); } } catch (error) { - console.error('Login error:', error); + console.error('MFA verification error:', error); toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: 'MFA 인증 중 오류가 발생했습니다.', variant: 'destructive', }); } finally { setIsLoading(false); } - } + }; + + // 일반 사용자명/패스워드 로그인 처리 (간소화된 버전) + const handleUsernameLogin = async (e: React.FormEvent) => { + e.preventDefault(); - // 새로운 로그인 처리 함수 추가 - const handleCredentialsLogin = async () => { if (!username || !password) { toast({ title: t('errorTitle'), @@ -170,24 +219,55 @@ export function LoginForm({ setIsLoading(true); try { - // next-auth의 다른 credentials provider로 로그인 시도 + // NextAuth credentials-password provider로 로그인 const result = await signIn('credentials-password', { - username, - password, + username: username, + password: password, redirect: false, }); if (result?.ok) { + // 로그인 1차 성공 - 바로 MFA 화면으로 전환 toast({ title: t('loginSuccess'), - description: t('youAreLoggedIn'), + description: '1차 인증이 완료되었습니다.', + }); + + // 모든 사용자는 MFA 필수이므로 바로 MFA 폼으로 전환 + setMfaUserId(username); // 입력받은 username 사용 + setMfaUserEmail(username); // 입력받은 username 사용 (보통 이메일) + setShowMfaForm(true); + + // 자동으로 SMS 전송 + setTimeout(() => { + handleSendSms(); + }, 500); + + toast({ + title: 'SMS 인증 필요', + description: '등록된 전화번호로 인증번호를 전송합니다.', }); - router.push(`/${lng}/partners/dashboard`); } else { + // 로그인 실패 처리 + let errorMessage = t('invalidCredentials'); + + if (result?.error) { + switch (result.error) { + case 'CredentialsSignin': + errorMessage = t('invalidCredentials'); + break; + case 'AccessDenied': + errorMessage = t('accessDenied'); + break; + default: + errorMessage = t('defaultErrorMessage'); + } + } + toast({ title: t('errorTitle'), - description: t('invalidCredentials'), + description: errorMessage, variant: 'destructive', }); } @@ -203,36 +283,87 @@ export function LoginForm({ } }; - useEffect(() => { - const verifyToken = async () => { - if (!token) return; - setIsLoading(true); - try { - const data = await verifyTokenAction(token); + // S-Gips 로그인 처리 + // S-Gips 로그인 처리 (간소화된 버전) + const handleSgipsLogin = async (e: React.FormEvent) => { + e.preventDefault(); - if (data.valid) { - setOtpSent(true); - setEmail(data.email ?? ''); - } else { - toast({ - title: t('errorTitle'), - description: t('invalidToken'), - variant: 'destructive', - }); + if (!sgipsUsername || !sgipsPassword) { + toast({ + title: t('errorTitle'), + description: t('credentialsRequired'), + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + + try { + // NextAuth credentials-password provider로 로그인 (S-Gips 구분) + const result = await signIn('credentials-password', { + username: sgipsUsername, + password: sgipsPassword, + provider: 'sgips', // S-Gips 구분을 위한 추가 파라미터 + redirect: false, + }); + + if (result?.ok) { + // S-Gips 1차 인증 성공 - 바로 MFA 화면으로 전환 + toast({ + title: t('loginSuccess'), + description: 'S-Gips 인증이 완료되었습니다.', + }); + + // S-Gips도 MFA 필수이므로 바로 MFA 폼으로 전환 + setMfaUserId(sgipsUsername); + setMfaUserEmail(sgipsUsername); + setShowMfaForm(true); + + // 자동으로 SMS 전송 + setTimeout(() => { + handleSendSms(); + }, 500); + + toast({ + title: 'SMS 인증 시작', + description: 'S-Gips 등록 전화번호로 인증번호를 전송합니다.', + }); + + } else { + let errorMessage = t('sgipsLoginFailed'); + + if (result?.error) { + switch (result.error) { + case 'CredentialsSignin': + errorMessage = t('invalidSgipsCredentials'); + break; + case 'AccessDenied': + errorMessage = t('sgipsAccessDenied'); + break; + default: + errorMessage = t('sgipsSystemError'); + } } - } catch (error) { + toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: errorMessage, variant: 'destructive', }); - } finally { - setIsLoading(false); } - }; - verifyToken(); - }, [token, toast, t]); + } catch (error) { + console.error('S-Gips login error:', error); + toast({ + title: t('errorTitle'), + description: t('sgipsSystemError'), + variant: 'destructive', + }); + } finally { + setIsLoading(false); + } + }; return ( <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> @@ -241,11 +372,6 @@ export function LoginForm({ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */} <div className="flex items-center justify-between"> <div className="flex items-center space-x-2"> - {/* <img - src="/images/logo.png" - alt="logo" - className="h-8 w-auto" - /> */} <Ship className="w-4 h-4" /> <span className="text-md font-bold">eVCP</span> </div> @@ -253,178 +379,350 @@ export function LoginForm({ href="/partners/repository" className={cn(buttonVariants({ variant: "ghost" }))} > - <InfoIcon className="w-4 h-4 mr-1" /> - {'업체 등록 신청'} + <InfoIcon className="w-4 h-4 mr-1" /> + {'업체 등록 신청'} </Link> </div> {/* Content section that occupies remaining space, centered vertically */} <div className="flex-1 flex items-center justify-center"> - {/* Your form container */} <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> - - {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - - <form onSubmit={handleSubmit} className="p-6 md:p-8"> - {/* <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> */} - <div className="flex flex-col gap-6"> + <div className="p-6 md:p-8"> + <div className="flex flex-col gap-6"> + {/* Header */} <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> - - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} - <p className="text-xs text-muted-foreground mt-2"> - {'등록된 업체만 로그인하실 수 있습니다. 아직도 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} - </p> - </div> - - {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( + {!showMfaForm ? ( <> - <div className="grid gap-2"> - <Input - id="email" - type="email" - placeholder={t('email')} - required - className="h-10" - value={email} - onChange={(e) => setEmail(e.target.value)} - /> + <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + <p className="text-xs text-muted-foreground mt-2"> + {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} + </p> + </> + ) : ( + <> + <div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 mb-4"> + 🔐 </div> - <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}> - {isLoading ? t('sending') : t('ContinueWithEmail')} - </Button> + <h1 className="text-2xl font-bold">SMS 인증</h1> + <p className="text-sm text-muted-foreground mt-2"> + {mfaUserEmail}로 로그인하셨습니다 + </p> + <p className="text-xs text-muted-foreground mt-1"> + 등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 + </p> + </> + )} + </div> - {/* 구분선과 "Or continue with" 섹션 추가 */} - <div className="relative"> - <div className="absolute inset-0 flex items-center"> - <span className="w-full border-t"></span> + {/* 로그인 폼 또는 MFA 폼 */} + {!showMfaForm ? ( + <> + {/* Login Method Tabs */} + <div className="flex rounded-lg bg-muted p-1"> + <button + type="button" + onClick={() => setLoginMethod('username')} + className={cn( + "flex-1 rounded-md px-3 py-2 text-sm font-medium transition-all", + loginMethod === 'username' + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground" + )} + > + 일반 로그인 + </button> + <button + type="button" + onClick={() => setLoginMethod('sgips')} + className={cn( + "flex-1 rounded-md px-3 py-2 text-sm font-medium transition-all", + loginMethod === 'sgips' + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground" + )} + > + S-Gips 로그인 + </button> + </div> + + {/* Username Login Form */} + {loginMethod === 'username' && ( + <form onSubmit={handleUsernameLogin} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="username" + type="text" + placeholder="이메일을 넣으세요" + required + className="h-10" + value={username} + onChange={(e) => setUsername(e.target.value)} + disabled={isLoading} + /> </div> - <div className="relative flex justify-center text-xs uppercase"> - <span className="bg-background px-2 text-muted-foreground"> - {t('orContinueWith')} - </span> + <div className="grid gap-2"> + <Input + id="password" + type="password" + placeholder={t('password')} + required + className="h-10" + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={isLoading} + /> </div> - </div> - - {/* S-Gips 로그인 버튼 */} + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isLoading || !username || !password} + > + {isLoading ? '로그인 중...' : t('login')} + </Button> + </form> + )} + + {/* S-Gips Login Form */} + {loginMethod === 'sgips' && ( + <form onSubmit={handleSgipsLogin} className="grid gap-4"> + <div className="grid gap-2"> + <Input + id="sgipsUsername" + type="text" + placeholder="S-Gips ID" + required + className="h-10" + value={sgipsUsername} + onChange={(e) => setSgipsUsername(e.target.value)} + disabled={isLoading} + /> + </div> + <div className="grid gap-2"> + <Input + id="sgipsPassword" + type="password" + placeholder="S-Gips 비밀번호" + required + className="h-10" + value={sgipsPassword} + onChange={(e) => setSgipsPassword(e.target.value)} + disabled={isLoading} + /> + </div> + <Button + type="submit" + className="w-full" + variant="default" + disabled={isLoading || !sgipsUsername || !sgipsPassword} + > + {isLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'} + </Button> + <p className="text-xs text-muted-foreground text-center"> + S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다. + </p> + </form> + )} + + {/* Additional Links */} + <div className="flex flex-col gap-2 text-center"> <Button type="button" - className="w-full" - // variant="" - onClick={() => setShowCredentialsForm(true)} + variant="link" + className="text-blue-600 hover:text-blue-800 text-sm" + onClick={goToVendorRegistration} > - S-Gips로 로그인하기 + {'신규 업체이신가요? 여기서 등록하세요'} </Button> - {/* 업체 등록 안내 링크 추가 */} - <Button - type="button" - variant="link" - className="text-blue-600 hover:text-blue-800" - onClick={goToVendorRegistration} - > - {'신규 업체이신가요? 여기서 등록하세요'} - </Button> - </> - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> - <div className="grid gap-4"> - <Input - id="username" - type="text" - placeholder="S-Gips ID" - className="h-10" - value={username} - onChange={(e) => setUsername(e.target.value)} - /> - <Input - id="password" - type="password" - placeholder="비밀번호" - className="h-10" - value={password} - onChange={(e) => setPassword(e.target.value)} - /> + {loginMethod === 'username' && ( <Button type="button" - className="w-full" - variant="samsung" - onClick={handleCredentialsLogin} - disabled={isLoading} + variant="link" + className="text-blue-600 hover:text-blue-800 text-sm" + onClick={() => setShowForgotPassword(true)} > - {isLoading ? "로그인 중..." : "로그인"} + 비밀번호를 잊으셨나요? </Button> + )} - {/* 뒤로 가기 버튼 */} + {/* 테스트용 MFA 화면 버튼 */} + {process.env.NODE_ENV === 'development' && ( <Button type="button" - variant="ghost" - className="w-full text-sm" - onClick={() => setShowCredentialsForm(false)} + variant="link" + className="text-green-600 hover:text-green-800 text-sm" + onClick={() => { + setMfaUserId('test-user'); + setMfaUserEmail('test@example.com'); + setShowMfaForm(true); + }} > - 이메일로 로그인하기 + [개발용] MFA 화면 테스트 </Button> + )} + </div> + </> + ) : ( + /* MFA 입력 폼 */ + <div className="space-y-6"> + {/* 뒤로 가기 버튼 */} + <div className="flex items-center justify-start"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + setShowMfaForm(false); + setMfaToken(''); + setMfaUserId(''); + setMfaUserEmail(''); + setMfaCountdown(0); + }} + className="text-blue-600 hover:text-blue-800" + > + <ArrowLeft className="w-4 h-4 mr-1" /> + 다시 로그인하기 + </Button> + </div> + + {/* SMS 재전송 섹션 */} + <div className="bg-gray-50 p-4 rounded-lg"> + <h3 className="text-sm font-medium text-gray-900 mb-2"> + 인증번호 재전송 + </h3> + <p className="text-xs text-gray-600 mb-3"> + 인증번호를 받지 못하셨나요? + </p> + <Button + onClick={handleSendSms} + disabled={isLoading || mfaCountdown > 0} + variant="outline" + size="sm" + className="w-full" + > + {isLoading ? ( + '전송 중...' + ) : mfaCountdown > 0 ? ( + `재전송 가능 (${mfaCountdown}초)` + ) : ( + '인증번호 재전송' + )} + </Button> + </div> + + {/* SMS 토큰 입력 폼 */} + <form onSubmit={handleMfaSubmit} className="space-y-6"> + <div className="space-y-4"> + <div className="text-center"> + <label className="block text-sm font-medium text-gray-700 mb-3"> + 6자리 인증번호를 입력해주세요 + </label> + <div className="flex justify-center"> + <InputOTP + maxLength={6} + value={mfaToken} + onChange={(value) => setMfaToken(value)} + > + <InputOTPGroup> + <InputOTPSlot index={0} /> + <InputOTPSlot index={1} /> + <InputOTPSlot index={2} /> + <InputOTPSlot index={3} /> + <InputOTPSlot index={4} /> + <InputOTPSlot index={5} /> + </InputOTPGroup> + </InputOTP> + </div> + </div> </div> - </> - )} - <div className="text-center text-sm mx-auto"> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="flex items-center gap-2"> - <GlobeIcon className="h-4 w-4" /> - <span>{currentLanguageText}</span> - <ChevronDownIcon className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuRadioGroup - value={i18n.language} - onValueChange={(value) => handleChangeLanguage(value)} - > - <DropdownMenuRadioItem value="en"> - {t('languages.english')} - </DropdownMenuRadioItem> - <DropdownMenuRadioItem value="ko"> - {t('languages.korean')} - </DropdownMenuRadioItem> - </DropdownMenuRadioGroup> - </DropdownMenuContent> - </DropdownMenu> - </div> - </div> - </form> - ) : ( - <form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8"> - <div className="flex flex-col gap-6"> - <div className="flex flex-col items-center text-center"> - <h1 className="text-2xl font-bold">{t('loginMessage')}</h1> + <Button + type="submit" + className="w-full" + variant="samsung" + disabled={isLoading || mfaToken.length !== 6} + > + {isLoading ? '인증 중...' : '인증 완료'} + </Button> + </form> + + {/* 도움말 */} + <div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200"> + <div className="flex"> + <div className="flex-shrink-0"> + ⚠️ + </div> + <div className="ml-2"> + <h4 className="text-xs font-medium text-yellow-800"> + 인증번호를 받지 못하셨나요? + </h4> + <div className="mt-1 text-xs text-yellow-700"> + <ul className="list-disc list-inside space-y-1"> + <li>전화번호가 올바른지 확인해주세요</li> + <li>스팸 메시지함을 확인해주세요</li> + <li>잠시 후 재전송 버튼을 이용해주세요</li> + </ul> + </div> + </div> + </div> + </div> </div> - <div className="grid gap-2 justify-center"> - <InputOTP - maxLength={6} - value={otp} - onChange={(value) => setOtp(value)} - > - <InputOTPGroup> - <InputOTPSlot index={0} /> - <InputOTPSlot index={1} /> - <InputOTPSlot index={2} /> - <InputOTPSlot index={3} /> - <InputOTPSlot index={4} /> - <InputOTPSlot index={5} /> - </InputOTPGroup> - </InputOTP> + )} + + {/* 비밀번호 재설정 다이얼로그 */} + {showForgotPassword && !showMfaForm && ( + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> + <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4"> + <div className="flex justify-between items-center mb-4"> + <h3 className="text-lg font-semibold">비밀번호 재설정</h3> + <button + onClick={() => { + setShowForgotPassword(false); + }} + className="text-gray-400 hover:text-gray-600" + > + ✕ + </button> + </div> + <form action={passwordResetAction} className="space-y-4"> + <div> + <p className="text-sm text-gray-600 mb-3"> + 가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다. + </p> + <Input + name="email" + type="email" + placeholder="이메일 주소" + required + /> + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + className="flex-1" + onClick={() => { + setShowForgotPassword(false); + }} + > + 취소 + </Button> + <Button + type="submit" + className="flex-1" + > + 재설정 링크 전송 + </Button> + </div> + </form> + </div> </div> - <Button type="submit" className="w-full" disabled={isLoading}> - {isLoading ? t('verifying') : t('verifyOtp')} - </Button> - <div className="mx-auto"> + )} + + {/* Language Selector - MFA 화면에서는 숨김 */} + {!showMfaForm && ( + <div className="text-center text-sm mx-auto"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="flex items-center gap-2"> @@ -448,21 +746,28 @@ export function LoginForm({ </DropdownMenuContent> </DropdownMenu> </div> - </div> - </form> - )} - - <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> - {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} - <a href="#">{t('privacyPolicy')}</a>. + )} + </div> </div> + + {/* Terms - MFA 화면에서는 숨김 */} + {!showMfaForm && ( + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> + {t("agreement")}{" "} + <Link + href={`/${lng}/privacy`} // 개인정보처리방침만 남김 + className="underline underline-offset-4 hover:text-primary" + > + {t("privacyPolicy")} + </Link> + </div> + )} </div> </div> </div> - {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + {/* Right BG 이미지 영역 */} <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex"> - {/* Image 컴포넌트로 대체 */} <div className="absolute inset-0"> <Image src="/images/02.jpg" @@ -476,7 +781,6 @@ export function LoginForm({ <div className="relative z-10 mt-auto"> <blockquote className="space-y-2"> <p className="text-sm">“{t("blockquote")}”</p> - {/* <footer className="text-sm">SHI</footer> */} </blockquote> </div> </div> |
