From e9897d416b3e7327bbd4d4aef887eee37751ae82 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 27 Jun 2025 01:16:20 +0000 Subject: (대표님) 20250627 오전 10시 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/login/login-form.tsx | 814 +++++++++++++++++++++++++++------------- 1 file changed, 559 insertions(+), 255 deletions(-) (limited to 'components/login/login-form.tsx') 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('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 (
@@ -241,11 +372,6 @@ export function LoginForm({ {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
- {/* logo */} eVCP
@@ -253,178 +379,350 @@ export function LoginForm({ href="/partners/repository" className={cn(buttonVariants({ variant: "ghost" }))} > - - {'업체 등록 신청'} + + {'업체 등록 신청'}
{/* Content section that occupies remaining space, centered vertically */}
- {/* Your form container */}
- - {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - -
- {/* */} -
+
+
+ {/* Header */}
-

{t('loginMessage')}

- - {/* 설명 텍스트 추가 - 업체 등록 관련 안내 */} -

- {'등록된 업체만 로그인하실 수 있습니다. 아직도 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} -

-
- - {/* S-Gips 로그인 폼이 표시되지 않을 때만 이메일 입력 필드 표시 */} - {!showCredentialsForm && ( + {!showMfaForm ? ( <> -
- setEmail(e.target.value)} - /> +

{t('loginMessage')}

+

+ {'등록된 업체만 로그인하실 수 있습니다. 아직 등록되지 않은 업체라면 상단의 업체 등록 신청 버튼을 이용해주세요.'} +

+ + ) : ( + <> +
+ 🔐
- +

SMS 인증

+

+ {mfaUserEmail}로 로그인하셨습니다 +

+

+ 등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 +

+ + )} +
- {/* 구분선과 "Or continue with" 섹션 추가 */} -
-
- + {/* 로그인 폼 또는 MFA 폼 */} + {!showMfaForm ? ( + <> + {/* Login Method Tabs */} +
+ + +
+ + {/* Username Login Form */} + {loginMethod === 'username' && ( + +
+ setUsername(e.target.value)} + disabled={isLoading} + />
-
- - {t('orContinueWith')} - +
+ setPassword(e.target.value)} + disabled={isLoading} + />
-
- - {/* S-Gips 로그인 버튼 */} + + + )} + + {/* S-Gips Login Form */} + {loginMethod === 'sgips' && ( +
+
+ setSgipsUsername(e.target.value)} + disabled={isLoading} + /> +
+
+ setSgipsPassword(e.target.value)} + disabled={isLoading} + /> +
+ +

+ S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다. +

+
+ )} + + {/* Additional Links */} +
- {/* 업체 등록 안내 링크 추가 */} - - - )} - - {/* ID/비밀번호 로그인 폼 - 버튼 클릭 시에만 표시 */} - {showCredentialsForm && ( - <> -
- setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> + {loginMethod === 'username' && ( + )} - {/* 뒤로 가기 버튼 */} + {/* 테스트용 MFA 화면 버튼 */} + {process.env.NODE_ENV === 'development' && ( + )} +
+ + ) : ( + /* MFA 입력 폼 */ +
+ {/* 뒤로 가기 버튼 */} +
+ +
+ + {/* SMS 재전송 섹션 */} +
+

+ 인증번호 재전송 +

+

+ 인증번호를 받지 못하셨나요? +

+ +
+ + {/* SMS 토큰 입력 폼 */} +
+
+
+ +
+ setMfaToken(value)} + > + + + + + + + + + +
+
- - )} -
- - - - - - handleChangeLanguage(value)} - > - - {t('languages.english')} - - - {t('languages.korean')} - - - - -
-
- - ) : ( -
-
-
-

{t('loginMessage')}

+ + + + {/* 도움말 */} +
+
+
+ ⚠️ +
+
+

+ 인증번호를 받지 못하셨나요? +

+
+
    +
  • 전화번호가 올바른지 확인해주세요
  • +
  • 스팸 메시지함을 확인해주세요
  • +
  • 잠시 후 재전송 버튼을 이용해주세요
  • +
+
+
+
+
-
- setOtp(value)} - > - - - - - - - - - + )} + + {/* 비밀번호 재설정 다이얼로그 */} + {showForgotPassword && !showMfaForm && ( +
+
+
+

비밀번호 재설정

+ +
+
+
+

+ 가입하신 이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다. +

+ +
+
+ + +
+
+
- -
+ )} + + {/* Language Selector - MFA 화면에서는 숨김 */} + {!showMfaForm && ( +
-
- - )} - -
- {t('termsMessage')} {t('termsOfService')} {t('and')} - {t('privacyPolicy')}. + )} +
+ + {/* Terms - MFA 화면에서는 숨김 */} + {!showMfaForm && ( +
+ {t("agreement")}{" "} + + {t("privacyPolicy")} + +
+ )}
- {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */} + {/* Right BG 이미지 영역 */}
- {/* Image 컴포넌트로 대체 */}

“{t("blockquote")}”

- {/* */}
-- cgit v1.2.3