summaryrefslogtreecommitdiff
path: root/components/login/login-form.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-27 01:16:20 +0000
commite9897d416b3e7327bbd4d4aef887eee37751ae82 (patch)
treebd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/login/login-form.tsx
parent3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff)
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/login/login-form.tsx')
-rw-r--r--components/login/login-form.tsx814
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">&ldquo;{t("blockquote")}&rdquo;</p>
- {/* <footer className="text-sm">SHI</footer> */}
</blockquote>
</div>
</div>