summaryrefslogtreecommitdiff
path: root/components/login
diff options
context:
space:
mode:
Diffstat (limited to 'components/login')
-rw-r--r--components/login/InvalidTokenPage.tsx45
-rw-r--r--components/login/SuccessPage.tsx53
-rw-r--r--components/login/login-form copy.tsx485
-rw-r--r--components/login/login-form.tsx814
-rw-r--r--components/login/next-auth-reauth-modal.tsx215
-rw-r--r--components/login/partner-auth-form.tsx16
-rw-r--r--components/login/privacy-policy-page.tsx733
-rw-r--r--components/login/reset-password.tsx351
8 files changed, 2446 insertions, 266 deletions
diff --git a/components/login/InvalidTokenPage.tsx b/components/login/InvalidTokenPage.tsx
new file mode 100644
index 00000000..da97a568
--- /dev/null
+++ b/components/login/InvalidTokenPage.tsx
@@ -0,0 +1,45 @@
+// app/[lng]/auth/reset-password/components/InvalidTokenPage.tsx
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { XCircle } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props {
+ expired: boolean;
+ error?: string;
+}
+
+export default function InvalidTokenPage({ expired, error }: Props) {
+ return (
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
+ <Card className="w-full max-w-md">
+ <CardHeader className="text-center">
+ <div className="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-red-100 mb-4">
+ <XCircle className="w-6 h-6 text-red-600" />
+ </div>
+ <CardTitle className="text-2xl">링크 오류</CardTitle>
+ <CardDescription>
+ {expired
+ ? '재설정 링크가 만료되었습니다. 새로운 재설정 요청을 해주세요.'
+ : error || '유효하지 않은 재설정 링크입니다.'}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ <Link href="/auth/forgot-password">
+ <Button className="w-full">
+ 새로운 재설정 링크 요청
+ </Button>
+ </Link>
+ <Link href="/auth/login">
+ <Button variant="outline" className="w-full">
+ 로그인 페이지로 돌아가기
+ </Button>
+ </Link>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/login/SuccessPage.tsx b/components/login/SuccessPage.tsx
new file mode 100644
index 00000000..f9a3c525
--- /dev/null
+++ b/components/login/SuccessPage.tsx
@@ -0,0 +1,53 @@
+// app/[lng]/auth/reset-password/components/SuccessPage.tsx
+
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { CheckCircle } from 'lucide-react';
+import Link from 'next/link';
+
+interface Props {
+ message?: string;
+}
+
+export default function SuccessPage({ message }: Props) {
+ const router = useRouter();
+
+ // 3초 후 자동 리다이렉트
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ router.push('/parnters');
+ }, 3000);
+
+ return () => clearTimeout(timer);
+ }, [router]);
+
+ return (
+ <Card className="w-full max-w-md">
+ <CardHeader className="text-center">
+ <div className="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-green-100 mb-4">
+ <CheckCircle className="w-6 h-6 text-green-600" />
+ </div>
+ <CardTitle className="text-2xl">재설정 완료</CardTitle>
+ <CardDescription>
+ {message || '비밀번호가 성공적으로 변경되었습니다.'}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="text-center space-y-4">
+ <p className="text-sm text-gray-600">
+ 잠시 후 로그인 페이지로 자동 이동합니다...
+ </p>
+ <Link href="/auth/login">
+ <Button className="w-full">
+ 지금 로그인하기
+ </Button>
+ </Link>
+ </div>
+ </CardContent>
+ </Card>
+ );
+} \ No newline at end of file
diff --git a/components/login/login-form copy.tsx b/components/login/login-form copy.tsx
new file mode 100644
index 00000000..4f9fbb53
--- /dev/null
+++ b/components/login/login-form copy.tsx
@@ -0,0 +1,485 @@
+'use client';
+
+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 { 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 {
+ 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
+
+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 handleChangeLanguage = (lang: string) => {
+ const segments = pathname.split('/');
+ segments[1] = lang;
+ router.push(segments.join('/'));
+ };
+
+ 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();
+ setIsLoading(true);
+ try {
+ const result = await sendOtpAction(email, lng);
+
+ if (result.success) {
+ setOtpSent(true);
+ toast({
+ title: t('otpSentTitle'),
+ description: t('otpSentMessage'),
+ });
+ } 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,
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ // This will catch network errors or other unexpected issues
+ console.error(error);
+ toast({
+ title: t('errorTitle'),
+ description: t('networkErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ async function handleOtpSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setIsLoading(true);
+
+ try {
+ // next-auth의 Credentials Provider로 로그인 시도
+ const result = await signIn('credentials', {
+ email,
+ code: otp,
+ redirect: false, // 커스텀 처리 위해 redirect: false
+ });
+
+ if (result?.ok) {
+ // 토스트 메시지 표시
+ toast({
+ title: t('loginSuccess'),
+ description: t('youAreLoggedIn'),
+ });
+
+ 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);
+ }
+ } else {
+ // callbackUrl이 없으면 기본 대시보드로 리다이렉트
+ router.push(`/${lng}/partners/dashboard`);
+ }
+
+ } else {
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // 새로운 로그인 처리 함수 추가
+ const handleCredentialsLogin = async () => {
+ if (!username || !password) {
+ toast({
+ title: t('errorTitle'),
+ description: t('credentialsRequired'),
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // next-auth의 다른 credentials provider로 로그인 시도
+ const result = await signIn('credentials-password', {
+ username,
+ password,
+ redirect: false,
+ });
+
+ if (result?.ok) {
+ toast({
+ title: t('loginSuccess'),
+ description: t('youAreLoggedIn'),
+ });
+
+ router.push(`/${lng}/partners/dashboard`);
+ } else {
+ toast({
+ title: t('errorTitle'),
+ description: t('invalidCredentials'),
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ console.error('Login error:', error);
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ const verifyToken = async () => {
+ if (!token) return;
+ setIsLoading(true);
+
+ try {
+ const data = await verifyTokenAction(token);
+
+ if (data.valid) {
+ setOtpSent(true);
+ setEmail(data.email ?? '');
+ } else {
+ toast({
+ title: t('errorTitle'),
+ description: t('invalidToken'),
+ variant: 'destructive',
+ });
+ }
+ } catch (error) {
+ toast({
+ title: t('errorTitle'),
+ description: t('defaultErrorMessage'),
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ verifyToken();
+ }, [token, toast, t]);
+
+ 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">
+ {/* Left Content */}
+ <div className="flex flex-col w-full h-screen lg:p-2">
+ {/* 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>
+ <Link
+ href="/partners/repository"
+ className={cn(buttonVariants({ variant: "ghost" }))}
+ >
+ <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="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 && (
+ <>
+ <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)}
+ />
+ </div>
+ <Button type="submit" className="w-full" variant="samsung" disabled={isLoading}>
+ {isLoading ? t('sending') : t('ContinueWithEmail')}
+ </Button>
+
+ {/* 구분선과 "Or continue with" 섹션 추가 */}
+ <div className="relative">
+ <div className="absolute inset-0 flex items-center">
+ <span className="w-full border-t"></span>
+ </div>
+ <div className="relative flex justify-center text-xs uppercase">
+ <span className="bg-background px-2 text-muted-foreground">
+ {t('orContinueWith')}
+ </span>
+ </div>
+ </div>
+
+ {/* S-Gips 로그인 버튼 */}
+ <Button
+ type="button"
+ className="w-full"
+ // variant=""
+ onClick={() => setShowCredentialsForm(true)}
+ >
+ 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)}
+ />
+ <Button
+ type="button"
+ className="w-full"
+ variant="samsung"
+ onClick={handleCredentialsLogin}
+ disabled={isLoading}
+ >
+ {isLoading ? "로그인 중..." : "로그인"}
+ </Button>
+
+ {/* 뒤로 가기 버튼 */}
+ <Button
+ type="button"
+ variant="ghost"
+ className="w-full text-sm"
+ onClick={() => setShowCredentialsForm(false)}
+ >
+ 이메일로 로그인하기
+ </Button>
+ </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>
+ </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>
+ </div>
+ <Button type="submit" className="w-full" disabled={isLoading}>
+ {isLoading ? t('verifying') : t('verifyOtp')}
+ </Button>
+ <div className="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>
+ )}
+
+ <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>
+ </div>
+ </div>
+
+ {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */}
+ <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"
+ alt="Background image"
+ fill
+ priority
+ sizes="(max-width: 1024px) 100vw, 50vw"
+ className="object-cover"
+ />
+ </div>
+ <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>
+ </div>
+ )
+} \ No newline at end of file
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>
diff --git a/components/login/next-auth-reauth-modal.tsx b/components/login/next-auth-reauth-modal.tsx
new file mode 100644
index 00000000..5aa61b7d
--- /dev/null
+++ b/components/login/next-auth-reauth-modal.tsx
@@ -0,0 +1,215 @@
+// components/auth/next-auth-reauth-modal.tsx
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { z } from "zod"
+import { signIn } from "next-auth/react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { toast } from "@/hooks/use-toast"
+import { AlertCircle, Shield } from "lucide-react"
+
+const reAuthSchema = z.object({
+ password: z.string().min(1, "Password is required"),
+})
+
+type ReAuthFormValues = z.infer<typeof reAuthSchema>
+
+interface NextAuthReAuthModalProps {
+ isOpen: boolean
+ onSuccess: () => void
+ userEmail: string
+}
+
+export function NextAuthReAuthModal({
+ isOpen,
+ onSuccess,
+ userEmail
+}: NextAuthReAuthModalProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [attemptCount, setAttemptCount] = React.useState(0)
+
+ const form = useForm<ReAuthFormValues>({
+ resolver: zodResolver(reAuthSchema),
+ defaultValues: {
+ password: "",
+ },
+ })
+
+ async function onSubmit(data: ReAuthFormValues) {
+ setIsLoading(true)
+
+ try {
+ // Next-auth의 signIn 함수를 사용하여 재인증
+ const result = await signIn("credentials", {
+ email: userEmail,
+ password: data.password,
+ redirect: false, // 리다이렉트 하지 않음
+ callbackUrl: undefined,
+ })
+
+ if (result?.error) {
+ setAttemptCount(prev => prev + 1)
+
+ // 3회 이상 실패 시 추가 보안 조치
+ if (attemptCount >= 2) {
+ toast({
+ title: "Too many failed attempts",
+ description: "Please wait a moment before trying again.",
+ variant: "destructive",
+ })
+ // 30초 대기
+ setTimeout(() => {
+ setAttemptCount(0)
+ }, 30000)
+ return
+ }
+
+ toast({
+ title: "Authentication failed",
+ description: `Invalid password. ${2 - attemptCount} attempts remaining.`,
+ variant: "destructive",
+ })
+
+ form.setError("password", {
+ type: "manual",
+ message: "Invalid password"
+ })
+ } else {
+ // 재인증 성공
+ setAttemptCount(0)
+ onSuccess()
+ form.reset()
+
+ toast({
+ title: "Authentication successful",
+ description: "You can now access account settings.",
+ })
+ }
+ } catch (error) {
+ console.error("Re-authentication error:", error)
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred. Please try again.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 모달이 닫힐 때 폼 리셋
+ React.useEffect(() => {
+ if (!isOpen) {
+ form.reset()
+ setAttemptCount(0)
+ }
+ }, [isOpen, form])
+
+ return (
+ <Dialog open={isOpen} onOpenChange={() => {}}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-3">
+ <div className="h-8 w-8 rounded-full bg-amber-100 flex items-center justify-center">
+ <Shield className="h-5 w-5 text-amber-600" />
+ </div>
+ Security Verification
+ </DialogTitle>
+ <DialogDescription className="text-left">
+ For your security, please confirm your password to access sensitive account settings.
+ This verification is valid for 5 minutes.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 사용자 정보 표시 */}
+ <div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
+ <div className="flex items-center gap-2">
+ <div className="h-2 w-2 bg-blue-500 rounded-full"></div>
+ <span className="text-sm font-medium text-blue-900">
+ Signed in as: {userEmail}
+ </span>
+ </div>
+ </div>
+
+ {/* 경고 메시지 (실패 횟수가 많을 때) */}
+ {attemptCount >= 2 && (
+ <div className="rounded-lg bg-red-50 border border-red-200 p-3">
+ <div className="flex items-start gap-2">
+ <AlertCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
+ <div className="text-sm text-red-800">
+ <p className="font-medium">Security Alert</p>
+ <p>Multiple failed attempts detected. Please wait 30 seconds before trying again.</p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Current Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Enter your password"
+ disabled={attemptCount >= 3 || isLoading}
+ {...field}
+ autoFocus
+ autoComplete="current-password"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Button
+ type="submit"
+ className="w-full"
+ disabled={isLoading || attemptCount >= 3}
+ >
+ {isLoading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
+ Verifying...
+ </>
+ ) : attemptCount >= 3 ? (
+ "Please wait..."
+ ) : (
+ "Verify Identity"
+ )}
+ </Button>
+ </form>
+ </Form>
+
+ <div className="text-xs text-muted-foreground text-center space-y-1">
+ <p>This helps protect your account from unauthorized changes.</p>
+ <p>Your session will remain active during verification.</p>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/login/partner-auth-form.tsx b/components/login/partner-auth-form.tsx
index ada64d96..5fed19cf 100644
--- a/components/login/partner-auth-form.tsx
+++ b/components/login/partner-auth-form.tsx
@@ -47,7 +47,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {
const params = useParams() || {};
const pathname = usePathname() || '';
-
+
const lng = params.lng as string
const { t, i18n } = useTranslation(lng, "login")
@@ -110,7 +110,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {
title: "가입이 진행 중이거나 완료된 회사",
description: `${result.data} 에 연락하여 계정 생성 요청을 하시기 바랍니다.`,
})
-
+
// 로그인 액션 버튼이 있는 알림 표시
setTimeout(() => {
toast({
@@ -244,6 +244,7 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {
variant="link"
className="text-blue-600 hover:text-blue-800 text-sm"
onClick={goToLogin}
+ type="button"
>
{t("alreadyRegistered") || "이미 등록된 업체이신가요? 로그인하기"}
</Button>
@@ -279,19 +280,12 @@ export function CompanyAuthForm({ className, ...props }: UserAuthFormProps) {
<p className="px-8 text-center text-sm text-muted-foreground">
{t("agreement")}{" "}
<Link
- href="/terms"
- className="underline underline-offset-4 hover:text-primary"
- >
- {t("termsOfService")}
- </Link>{" "}
- {t("and")}{" "}
- <Link
- href="/privacy"
+ href={`/${lng}/privacy`} // 개인정보처리방침만 남김
className="underline underline-offset-4 hover:text-primary"
>
{t("privacyPolicy")}
</Link>
- .
+ {/* {t("privacyAgreement")}. */}
</p>
</div>
</div>
diff --git a/components/login/privacy-policy-page.tsx b/components/login/privacy-policy-page.tsx
new file mode 100644
index 00000000..e3eccdcb
--- /dev/null
+++ b/components/login/privacy-policy-page.tsx
@@ -0,0 +1,733 @@
+"use client"
+
+import * as React from "react"
+import { useRouter, useParams } from "next/navigation"
+import Link from "next/link"
+import { Ship, ArrowLeft } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+// 한국어 개인정보처리방침 컴포넌트
+function PrivacyPolicyPageKo() {
+ const router = useRouter()
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ {/* Header */}
+ <div className="bg-white border-b">
+ <div className="container mx-auto px-4 py-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Ship className="w-6 h-6 text-blue-600" />
+ <span className="text-xl font-bold">eVCP</span>
+ </div>
+ <Button
+ variant="ghost"
+ onClick={() => router.back()}
+ className="flex items-center space-x-2"
+ >
+ <ArrowLeft className="w-4 h-4" />
+ <span>뒤로가기</span>
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="container mx-auto px-4 py-8 max-w-4xl">
+ <div className="bg-white rounded-lg shadow-sm p-8">
+ <header className="mb-8">
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">
+ 개인정보처리방침
+ </h1>
+ <p className="text-gray-600">
+ 시행일자: 2025년 1월 1일
+ </p>
+ </header>
+
+ <div className="prose prose-lg max-w-none">
+ <div className="mb-6 p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500">
+ <p className="text-blue-800 font-medium mb-2">
+ eVCP는 개인정보보호법, 정보통신망 이용촉진 및 정보보호 등에 관한 법률 등
+ 개인정보보호 관련 법령을 준수하며, 이용자의 개인정보를 안전하게 처리하고 있습니다.
+ </p>
+ </div>
+
+ {/* 목차 */}
+ <div className="mb-8 p-6 bg-gray-50 rounded-lg">
+ <h3 className="text-lg font-semibold mb-4">목차</h3>
+ <ul className="space-y-2 text-sm">
+ <li><a href="#section1" className="text-blue-600 hover:underline">1. 개인정보의 수집 및 이용목적</a></li>
+ <li><a href="#section2" className="text-blue-600 hover:underline">2. 수집하는 개인정보의 항목</a></li>
+ <li><a href="#section3" className="text-blue-600 hover:underline">3. 개인정보의 보유 및 이용기간</a></li>
+ <li><a href="#section4" className="text-blue-600 hover:underline">4. 개인정보의 제3자 제공</a></li>
+ <li><a href="#section5" className="text-blue-600 hover:underline">5. 개인정보 처리의 위탁</a></li>
+ <li><a href="#section6" className="text-blue-600 hover:underline">6. 개인정보 주체의 권리</a></li>
+ <li><a href="#section7" className="text-blue-600 hover:underline">7. 개인정보의 파기절차 및 방법</a></li>
+ <li><a href="#section8" className="text-blue-600 hover:underline">8. 개인정보 보호책임자</a></li>
+ <li><a href="#section9" className="text-blue-600 hover:underline">9. 개인정보의 안전성 확보조치</a></li>
+ <li><a href="#section10" className="text-blue-600 hover:underline">10. 쿠키의 설치·운영 및 거부</a></li>
+ <li><a href="#section11" className="text-blue-600 hover:underline">11. 개인정보 처리방침의 변경</a></li>
+ </ul>
+ </div>
+
+ {/* 각 섹션 */}
+ <section id="section1" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 1. 개인정보의 수집 및 이용목적
+ </h2>
+ <p className="mb-4">회사는 다음의 목적을 위하여 개인정보를 수집 및 이용합니다:</p>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.1 회원가입 및 계정관리</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>회원 식별 및 본인인증</li>
+ <li>회원자격 유지·관리</li>
+ <li>서비스 부정이용 방지</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.2 서비스 제공</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>업체 등록 및 인증 서비스 제공</li>
+ <li>고객상담 및 문의사항 처리</li>
+ <li>공지사항 전달</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.3 법정의무 이행</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>관련 법령에 따른 의무사항 이행</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section2" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 2. 수집하는 개인정보의 항목
+ </h2>
+
+ <div className="space-y-4">
+ <div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
+ <h3 className="text-lg font-medium mb-2 text-yellow-800">2.1 필수정보</h3>
+ <ul className="list-disc pl-6 space-y-1 text-yellow-700">
+ <li><strong>이메일 주소</strong>: 계정 생성, 로그인, 중요 알림 발송</li>
+ <li><strong>전화번호</strong>: 본인인증, 중요 연락사항 전달</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">2.2 자동 수집정보</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>접속 IP주소, 접속 시간, 이용기록</li>
+ <li>쿠키, 서비스 이용기록</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section3" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 3. 개인정보의 보유 및 이용기간
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">3.1 회원정보</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>보유기간</strong>: 회원탈퇴 시까지</li>
+ <li><strong>예외</strong>: 관련 법령에 따라 보존할 필요가 있는 경우 해당 기간 동안 보관</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">3.2 법령에 따른 보관</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>계약 또는 청약철회 등에 관한 기록</strong>: 5년 (전자상거래법)</li>
+ <li><strong>대금결제 및 재화 등의 공급에 관한 기록</strong>: 5년 (전자상거래법)</li>
+ <li><strong>소비자 불만 또는 분쟁처리에 관한 기록</strong>: 3년 (전자상거래법)</li>
+ <li><strong>웹사이트 방문기록</strong>: 3개월 (통신비밀보호법)</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section4" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 4. 개인정보의 제3자 제공
+ </h2>
+ <p className="mb-4">
+ 회사는 원칙적으로 이용자의 개인정보를 제3자에게 제공하지 않습니다.
+ </p>
+ <p className="mb-2">다만, 다음의 경우에는 예외로 합니다:</p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>이용자가 사전에 동의한 경우</li>
+ <li>법령의 규정에 의거하거나, 수사 목적으로 법령에 정해진 절차와 방법에 따라 수사기관의 요구가 있는 경우</li>
+ </ul>
+ </section>
+
+ <section id="section5" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 5. 개인정보 처리의 위탁
+ </h2>
+ <p className="mb-4">
+ 현재 회사는 개인정보 처리업무를 외부에 위탁하고 있지 않습니다.
+ </p>
+ <p className="mb-2">향후 개인정보 처리업무를 위탁하는 경우, 다음 사항을 준수하겠습니다:</p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>위탁계약 체결 시 개인정보보호 관련 법령 준수, 개인정보에 관한 비밀유지, 제3자 제공 금지 등을 계약서에 명시</li>
+ <li>위탁업체가 개인정보를 안전하게 처리하는지 감독</li>
+ </ul>
+ </section>
+
+ {/* 권리 행사 섹션 - 특별히 강조 */}
+ <section id="section6" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 6. 개인정보 주체의 권리
+ </h2>
+
+ <div className="p-6 bg-blue-50 rounded-lg border border-blue-200 mb-4">
+ <p className="text-blue-800 font-medium mb-2">
+ 💡 이용자님의 권리를 알려드립니다
+ </p>
+ <p className="text-blue-700 text-sm">
+ 언제든지 본인의 개인정보에 대해 열람, 수정, 삭제를 요청하실 수 있습니다.
+ </p>
+ </div>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">6.1 정보주체의 권리</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>열람권</strong>: 본인의 개인정보 처리현황을 확인할 권리</li>
+ <li><strong>정정·삭제권</strong>: 잘못된 정보의 수정이나 삭제를 요구할 권리</li>
+ <li><strong>처리정지권</strong>: 개인정보 처리 중단을 요구할 권리</li>
+ </ul>
+ </div>
+
+ <div className="p-4 bg-gray-50 rounded-lg">
+ <h3 className="text-lg font-medium mb-2">6.2 권리행사 방법</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>연락처</strong>: privacy@evcp.com</li>
+ <li><strong>처리기간</strong>: 요청 접수 후 10일 이내</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section7" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 7. 개인정보의 파기절차 및 방법
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">7.1 파기절차</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>보유기간 만료 또는 처리목적 달성 시 지체없이 파기</li>
+ <li>다른 법령에 따라 보관하여야 하는 경우에는 해당 기간 동안 보관</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">7.2 파기방법</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>전자적 파일</strong>: 복구 및 재생되지 않도록 안전하게 삭제</li>
+ <li><strong>서면</strong>: 분쇄기로 분쇄하거나 소각</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ {/* 연락처 정보 */}
+ <section id="section8" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 8. 개인정보 보호책임자
+ </h2>
+
+ <div className="grid md:grid-cols-2 gap-6">
+ <div className="p-4 border rounded-lg">
+ <h3 className="text-lg font-medium mb-2">개인정보 보호책임자</h3>
+ <ul className="space-y-1 text-sm">
+ <li><strong>성명</strong>: [담당자명]</li>
+ <li><strong>직책</strong>: [직책명]</li>
+ <li><strong>연락처</strong>: privacy@evcp.com</li>
+ </ul>
+ </div>
+
+ <div className="p-4 border rounded-lg">
+ <h3 className="text-lg font-medium mb-2">개인정보 보호담당자</h3>
+ <ul className="space-y-1 text-sm">
+ <li><strong>성명</strong>: [담당자명]</li>
+ <li><strong>부서</strong>: [부서명]</li>
+ <li><strong>연락처</strong>: privacy@evcp.com, 02-0000-0000</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section9" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 9. 개인정보의 안전성 확보조치
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.1 기술적 조치</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>개인정보 암호화</li>
+ <li>해킹 등에 대비한 기술적 대책</li>
+ <li>백신 소프트웨어 등의 설치·갱신</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.2 관리적 조치</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>개인정보 취급자의 최소한 지정 및 교육</li>
+ <li>개인정보 취급자에 대한 정기적 교육</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.3 물리적 조치</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>전산실, 자료보관실 등의 접근통제</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section10" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 10. 쿠키의 설치·운영 및 거부
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">10.1 쿠키의 사용목적</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>이용자에게 최적화된 서비스 제공</li>
+ <li>웹사이트 방문 및 이용형태 파악</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">10.2 쿠키 거부 방법</h3>
+ <p className="mb-2">웹브라우저 설정을 통해 쿠키 허용, 차단 등의 설정을 변경할 수 있습니다.</p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Chrome: 설정 → 개인정보 및 보안 → 쿠키 및 기타 사이트 데이터</li>
+ <li>Safari: 환경설정 → 개인정보 보호 → 쿠키 및 웹사이트 데이터</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section11" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 11. 개인정보 처리방침의 변경
+ </h2>
+ <p>
+ 본 개인정보처리방침은 법령·정책 또는 보안기술의 변경에 따라 내용의 추가·삭제 및 수정이 있을 시
+ 변경 최소 7일 전부터 웹사이트를 통해 변경이유 및 내용 등을 공지하겠습니다.
+ </p>
+ <div className="mt-4 p-4 bg-gray-50 rounded-lg">
+ <p><strong>공고일자</strong>: 2025년 1월 1일</p>
+ <p><strong>시행일자</strong>: 2025년 1월 1일</p>
+ </div>
+ </section>
+
+ {/* 문의처 */}
+ <div className="mt-12 p-6 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg border border-blue-200">
+ <h3 className="text-xl font-semibold mb-4 text-blue-900">문의처</h3>
+ <p className="text-blue-800 mb-4">
+ 개인정보와 관련한 문의사항이 있으시면 아래 연락처로 문의해 주시기 바랍니다.
+ </p>
+ <div className="space-y-2 text-blue-700">
+ <p><strong>이메일</strong>: privacy@evcp.com</p>
+ <p><strong>전화</strong>: 02-0000-0000</p>
+ <p><strong>주소</strong>: [회사 주소]</p>
+ </div>
+ </div>
+
+ <div className="mt-8 text-center text-sm text-gray-500">
+ <p>본 방침은 2025년 1월 1일부터 시행됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+// 영문 개인정보처리방침 컴포넌트
+function PrivacyPolicyPageEn() {
+ const router = useRouter()
+
+ return (
+ <div className="min-h-screen bg-gray-50">
+ {/* Header */}
+ <div className="bg-white border-b">
+ <div className="container mx-auto px-4 py-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Ship className="w-6 h-6 text-blue-600" />
+ <span className="text-xl font-bold">eVCP</span>
+ </div>
+ <Button
+ variant="ghost"
+ onClick={() => router.back()}
+ className="flex items-center space-x-2"
+ >
+ <ArrowLeft className="w-4 h-4" />
+ <span>Back</span>
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="container mx-auto px-4 py-8 max-w-4xl">
+ <div className="bg-white rounded-lg shadow-sm p-8">
+ <header className="mb-8">
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">
+ Privacy Policy
+ </h1>
+ <p className="text-gray-600">
+ Effective Date: January 1, 2025
+ </p>
+ </header>
+
+ <div className="prose prose-lg max-w-none">
+ <div className="mb-6 p-4 bg-blue-50 rounded-lg border-l-4 border-blue-500">
+ <p className="text-blue-800 font-medium mb-2">
+ eVCP complies with applicable privacy and data protection laws and regulations,
+ and is committed to protecting and securely processing your personal information.
+ </p>
+ </div>
+
+ {/* Table of Contents */}
+ <div className="mb-8 p-6 bg-gray-50 rounded-lg">
+ <h3 className="text-lg font-semibold mb-4">Table of Contents</h3>
+ <ul className="space-y-2 text-sm">
+ <li><a href="#section1" className="text-blue-600 hover:underline">1. Purpose of Personal Information Collection and Use</a></li>
+ <li><a href="#section2" className="text-blue-600 hover:underline">2. Personal Information We Collect</a></li>
+ <li><a href="#section3" className="text-blue-600 hover:underline">3. Retention and Use Period</a></li>
+ <li><a href="#section4" className="text-blue-600 hover:underline">4. Third Party Disclosure</a></li>
+ <li><a href="#section5" className="text-blue-600 hover:underline">5. Processing Outsourcing</a></li>
+ <li><a href="#section6" className="text-blue-600 hover:underline">6. Your Rights</a></li>
+ <li><a href="#section7" className="text-blue-600 hover:underline">7. Data Deletion Procedures</a></li>
+ <li><a href="#section8" className="text-blue-600 hover:underline">8. Privacy Officer</a></li>
+ <li><a href="#section9" className="text-blue-600 hover:underline">9. Security Measures</a></li>
+ <li><a href="#section10" className="text-blue-600 hover:underline">10. Cookies</a></li>
+ <li><a href="#section11" className="text-blue-600 hover:underline">11. Policy Changes</a></li>
+ </ul>
+ </div>
+
+ {/* Sections */}
+ <section id="section1" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 1. Purpose of Personal Information Collection and Use
+ </h2>
+ <p className="mb-4">We collect and use personal information for the following purposes:</p>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.1 Account Registration and Management</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>User identification and authentication</li>
+ <li>Account maintenance and management</li>
+ <li>Prevention of service misuse</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.2 Service Provision</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Company registration and verification services</li>
+ <li>Customer support and inquiry handling</li>
+ <li>Important notifications and announcements</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">1.3 Legal Compliance</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Compliance with applicable laws and regulations</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section2" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 2. Personal Information We Collect
+ </h2>
+
+ <div className="space-y-4">
+ <div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
+ <h3 className="text-lg font-medium mb-2 text-yellow-800">2.1 Required Information</h3>
+ <ul className="list-disc pl-6 space-y-1 text-yellow-700">
+ <li><strong>Email Address</strong>: Account creation, login, important notifications</li>
+ <li><strong>Phone Number</strong>: Identity verification, important communications</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">2.2 Automatically Collected Information</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>IP address, access time, usage records</li>
+ <li>Cookies and service usage records</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section3" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 3. Retention and Use Period
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">3.1 Member Information</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>Retention Period</strong>: Until account deletion</li>
+ <li><strong>Exception</strong>: Where required by law, retained for the required period</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">3.2 Legal Retention Requirements</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>Contract and transaction records</strong>: 5 years</li>
+ <li><strong>Payment and service delivery records</strong>: 5 years</li>
+ <li><strong>Consumer complaint or dispute records</strong>: 3 years</li>
+ <li><strong>Website visit records</strong>: 3 months</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section4" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 4. Third Party Disclosure
+ </h2>
+ <p className="mb-4">
+ We do not disclose your personal information to third parties, except in the following cases:
+ </p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>With your prior consent</li>
+ <li>When required by law or legal authorities following proper procedures</li>
+ </ul>
+ </section>
+
+ <section id="section5" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 5. Processing Outsourcing
+ </h2>
+ <p className="mb-4">
+ We currently do not outsource personal information processing to external parties.
+ </p>
+ <p className="mb-2">If we outsource personal information processing in the future, we will:</p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Include privacy protection clauses in outsourcing contracts</li>
+ <li>Supervise outsourced parties to ensure secure processing of personal information</li>
+ </ul>
+ </section>
+
+ {/* Rights section - emphasized */}
+ <section id="section6" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 6. Your Rights
+ </h2>
+
+ <div className="p-6 bg-blue-50 rounded-lg border border-blue-200 mb-4">
+ <p className="text-blue-800 font-medium mb-2">
+ 💡 Know Your Rights
+ </p>
+ <p className="text-blue-700 text-sm">
+ You can request access, correction, or deletion of your personal information at any time.
+ </p>
+ </div>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">6.1 Data Subject Rights</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>Right of Access</strong>: Request information about how your data is processed</li>
+ <li><strong>Right of Rectification</strong>: Request correction or deletion of incorrect information</li>
+ <li><strong>Right to Restriction</strong>: Request suspension of personal information processing</li>
+ </ul>
+ </div>
+
+ <div className="p-4 bg-gray-50 rounded-lg">
+ <h3 className="text-lg font-medium mb-2">6.2 How to Exercise Your Rights</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>Contact</strong>: privacy@evcp.com</li>
+ <li><strong>Response Time</strong>: Within 10 days of request</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section7" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 7. Data Deletion Procedures
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">7.1 Deletion Procedure</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Deletion without delay when retention period expires or purpose is achieved</li>
+ <li>Retention for required period when required by other laws</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">7.2 Deletion Method</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li><strong>Electronic files</strong>: Secure deletion to prevent recovery</li>
+ <li><strong>Paper documents</strong>: Shredding or incineration</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ {/* Contact information */}
+ <section id="section8" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 8. Privacy Officer
+ </h2>
+
+ <div className="grid md:grid-cols-2 gap-6">
+ <div className="p-4 border rounded-lg">
+ <h3 className="text-lg font-medium mb-2">Chief Privacy Officer</h3>
+ <ul className="space-y-1 text-sm">
+ <li><strong>Name</strong>: [Officer Name]</li>
+ <li><strong>Title</strong>: [Title]</li>
+ <li><strong>Contact</strong>: privacy@evcp.com</li>
+ </ul>
+ </div>
+
+ <div className="p-4 border rounded-lg">
+ <h3 className="text-lg font-medium mb-2">Privacy Manager</h3>
+ <ul className="space-y-1 text-sm">
+ <li><strong>Name</strong>: [Manager Name]</li>
+ <li><strong>Department</strong>: [Department]</li>
+ <li><strong>Contact</strong>: privacy@evcp.com, +82-2-0000-0000</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section9" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 9. Security Measures
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.1 Technical Measures</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Personal information encryption</li>
+ <li>Technical safeguards against hacking</li>
+ <li>Installation and updating of antivirus software</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.2 Administrative Measures</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Minimizing and training personal information handlers</li>
+ <li>Regular training for personal information handlers</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">9.3 Physical Measures</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Access control to computer rooms and data storage areas</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section10" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 10. Cookies
+ </h2>
+
+ <div className="space-y-4">
+ <div>
+ <h3 className="text-lg font-medium mb-2">10.1 Purpose of Cookie Use</h3>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Providing optimized services to users</li>
+ <li>Understanding website visit and usage patterns</li>
+ </ul>
+ </div>
+
+ <div>
+ <h3 className="text-lg font-medium mb-2">10.2 Cookie Management</h3>
+ <p className="mb-2">You can control cookie settings through your web browser:</p>
+ <ul className="list-disc pl-6 space-y-1">
+ <li>Chrome: Settings → Privacy and security → Cookies and other site data</li>
+ <li>Safari: Preferences → Privacy → Cookies and website data</li>
+ </ul>
+ </div>
+ </div>
+ </section>
+
+ <section id="section11" className="mb-8">
+ <h2 className="text-2xl font-semibold mb-4 text-gray-900">
+ 11. Policy Changes
+ </h2>
+ <p>
+ This Privacy Policy may be updated due to changes in laws, policies, or security technology.
+ We will notify users of changes at least 7 days in advance through our website.
+ </p>
+ <div className="mt-4 p-4 bg-gray-50 rounded-lg">
+ <p><strong>Publication Date</strong>: January 1, 2025</p>
+ <p><strong>Effective Date</strong>: January 1, 2025</p>
+ </div>
+ </section>
+
+ {/* Contact section */}
+ <div className="mt-12 p-6 bg-gradient-to-r from-blue-50 to-blue-100 rounded-lg border border-blue-200">
+ <h3 className="text-xl font-semibold mb-4 text-blue-900">Contact Us</h3>
+ <p className="text-blue-800 mb-4">
+ If you have any questions about this Privacy Policy, please contact us:
+ </p>
+ <div className="space-y-2 text-blue-700">
+ <p><strong>Email</strong>: privacy@evcp.com</p>
+ <p><strong>Phone</strong>: +82-2-0000-0000</p>
+ <p><strong>Address</strong>: [Company Address]</p>
+ </div>
+ </div>
+
+ <div className="mt-8 text-center text-sm text-gray-500">
+ <p>This policy is effective from January 1, 2025.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+// 메인 컴포넌트 - 언어에 따라 조건부 렌더링
+export function PrivacyPolicyPage() {
+ const params = useParams() || {};
+ const lng = params.lng as string
+
+ // 한국어면 한국어 버전, 그 외는 영문 버전
+ if (lng === 'ko') {
+ return <PrivacyPolicyPageKo />
+ } else {
+ return <PrivacyPolicyPageEn />
+ }
+} \ No newline at end of file
diff --git a/components/login/reset-password.tsx b/components/login/reset-password.tsx
new file mode 100644
index 00000000..f68018d9
--- /dev/null
+++ b/components/login/reset-password.tsx
@@ -0,0 +1,351 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import { useFormState } from 'react-dom';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Ship, Eye, EyeOff, CheckCircle, XCircle, AlertCircle, Shield } from 'lucide-react';
+import Link from 'next/link';
+import SuccessPage from './SuccessPage';
+import { PasswordPolicy } from '@/lib/users/auth/passwordUtil';
+import { PasswordValidationResult, resetPasswordAction, validatePasswordAction } from '@/lib/users/auth/partners-auth';
+
+interface PasswordRequirement {
+ text: string;
+ met: boolean;
+ type: 'length' | 'uppercase' | 'lowercase' | 'number' | 'symbol' | 'pattern';
+}
+
+interface Props {
+ token: string;
+ userId: number;
+ passwordPolicy: PasswordPolicy;
+}
+
+export default function ResetPasswordForm({ token, userId, passwordPolicy }: Props) {
+ const router = useRouter();
+ const { toast } = useToast();
+
+ // 상태 관리
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [passwordValidation, setPasswordValidation] = useState<PasswordValidationResult | null>(null);
+ const [isValidatingPassword, setIsValidatingPassword] = useState(false);
+
+ // 서버 액션 상태
+ const [resetState, resetAction] = useFormState(resetPasswordAction, {
+ success: false,
+ error: undefined,
+ message: undefined,
+ });
+
+ // 패스워드 검증 (디바운싱 적용)
+ useEffect(() => {
+ const validatePassword = async () => {
+ if (!newPassword) {
+ setPasswordValidation(null);
+ return;
+ }
+
+ setIsValidatingPassword(true);
+
+ try {
+ // 사용자 ID를 포함한 검증 (히스토리 체크 포함)
+ const validation = await validatePasswordAction(newPassword, userId);
+ setPasswordValidation(validation);
+ } catch (error) {
+ console.error('Password validation error:', error);
+ setPasswordValidation(null);
+ } finally {
+ setIsValidatingPassword(false);
+ }
+ };
+
+ // 디바운싱: 500ms 후에 검증 실행
+ const timeoutId = setTimeout(validatePassword, 500);
+ return () => clearTimeout(timeoutId);
+ }, [newPassword, userId]);
+
+ // 서버 액션 결과 처리
+ useEffect(() => {
+ if (resetState.error) {
+ toast({
+ title: '오류',
+ description: resetState.error,
+ variant: 'destructive',
+ });
+ }
+ }, [resetState, toast]);
+
+ // 패스워드 요구사항 생성
+ const getPasswordRequirements = (): PasswordRequirement[] => {
+ if (!passwordValidation) return [];
+
+ const { strength } = passwordValidation;
+ const requirements: PasswordRequirement[] = [
+ {
+ text: `${passwordPolicy.minLength}자 이상`,
+ met: strength.length >= passwordPolicy.minLength,
+ type: 'length'
+ }
+ ];
+
+ if (passwordPolicy.requireUppercase) {
+ requirements.push({
+ text: '대문자 포함',
+ met: strength.hasUppercase,
+ type: 'uppercase'
+ });
+ }
+
+ if (passwordPolicy.requireLowercase) {
+ requirements.push({
+ text: '소문자 포함',
+ met: strength.hasLowercase,
+ type: 'lowercase'
+ });
+ }
+
+ if (passwordPolicy.requireNumbers) {
+ requirements.push({
+ text: '숫자 포함',
+ met: strength.hasNumbers,
+ type: 'number'
+ });
+ }
+
+ if (passwordPolicy.requireSymbols) {
+ requirements.push({
+ text: '특수문자 포함',
+ met: strength.hasSymbols,
+ type: 'symbol'
+ });
+ }
+
+ return requirements;
+ };
+
+ // 패스워드 강도 색상
+ const getStrengthColor = (score: number) => {
+ switch (score) {
+ case 1: return 'text-red-600';
+ case 2: return 'text-orange-600';
+ case 3: return 'text-yellow-600';
+ case 4: return 'text-blue-600';
+ case 5: return 'text-green-600';
+ default: return 'text-gray-600';
+ }
+ };
+
+ const getStrengthText = (score: number) => {
+ switch (score) {
+ case 1: return '매우 약함';
+ case 2: return '약함';
+ case 3: return '보통';
+ case 4: return '강함';
+ case 5: return '매우 강함';
+ default: return '';
+ }
+ };
+
+ const passwordRequirements = getPasswordRequirements();
+ const allRequirementsMet = passwordValidation?.policyValid && passwordValidation?.historyValid !== false;
+ const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
+ const canSubmit = allRequirementsMet && passwordsMatch && !isValidatingPassword;
+
+ // 성공 화면
+ if (resetState.success) {
+ return <SuccessPage message={resetState.message} />;
+ }
+
+ return (
+ <Card className="w-full max-w-md">
+ <CardHeader className="text-center">
+ <div className="mx-auto flex items-center justify-center space-x-2 mb-4">
+ <Ship className="w-6 h-6 text-blue-600" />
+ <span className="text-xl font-bold">eVCP</span>
+ </div>
+ <CardTitle className="text-2xl">새 비밀번호 설정</CardTitle>
+ <CardDescription>
+ 계정 보안을 위해 강력한 비밀번호를 설정해주세요.
+ </CardDescription>
+ </CardHeader>
+
+ <CardContent>
+ <form action={resetAction} className="space-y-6">
+ <input type="hidden" name="token" value={token} />
+
+ {/* 새 비밀번호 */}
+ <div className="space-y-2">
+ <label htmlFor="newPassword" className="text-sm font-medium text-gray-700">
+ 새 비밀번호
+ </label>
+ <div className="relative">
+ <Input
+ id="newPassword"
+ name="newPassword"
+ type={showPassword ? "text" : "password"}
+ value={newPassword}
+ onChange={(e) => setNewPassword(e.target.value)}
+ placeholder="새 비밀번호를 입력하세요"
+ required
+ />
+ <button
+ type="button"
+ className="absolute inset-y-0 right-0 pr-3 flex items-center"
+ onClick={() => setShowPassword(!showPassword)}
+ >
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+ </button>
+ </div>
+
+ {/* 패스워드 강도 표시 */}
+ {passwordValidation && (
+ <div className="mt-2 space-y-2">
+ <div className="flex items-center space-x-2">
+ <Shield className="h-4 w-4 text-gray-500" />
+ <span className="text-xs text-gray-600">강도:</span>
+ <span className={`text-xs font-medium ${getStrengthColor(passwordValidation.strength.score)}`}>
+ {getStrengthText(passwordValidation.strength.score)}
+ </span>
+ {isValidatingPassword && (
+ <div className="ml-2 animate-spin rounded-full h-3 w-3 border-b border-blue-600"></div>
+ )}
+ </div>
+
+ {/* 강도 진행바 */}
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className={`h-2 rounded-full transition-all duration-300 ${
+ passwordValidation.strength.score === 1 ? 'bg-red-500' :
+ passwordValidation.strength.score === 2 ? 'bg-orange-500' :
+ passwordValidation.strength.score === 3 ? 'bg-yellow-500' :
+ passwordValidation.strength.score === 4 ? 'bg-blue-500' :
+ 'bg-green-500'
+ }`}
+ style={{ width: `${(passwordValidation.strength.score / 5) * 100}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 패스워드 요구사항 */}
+ {passwordRequirements.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {passwordRequirements.map((req, index) => (
+ <div key={index} className="flex items-center space-x-2 text-xs">
+ {req.met ? (
+ <CheckCircle className="h-3 w-3 text-green-500" />
+ ) : (
+ <XCircle className="h-3 w-3 text-red-500" />
+ )}
+ <span className={req.met ? 'text-green-700' : 'text-red-700'}>
+ {req.text}
+ </span>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 히스토리 검증 결과 */}
+ {passwordValidation?.historyValid === false && (
+ <div className="mt-2">
+ <div className="flex items-center space-x-2 text-xs">
+ <XCircle className="h-3 w-3 text-red-500" />
+ <span className="text-red-700">
+ 최근 {passwordPolicy.historyCount}개 비밀번호와 달라야 합니다
+ </span>
+ </div>
+ </div>
+ )}
+
+ {/* 추가 피드백 */}
+ {passwordValidation?.strength.feedback && passwordValidation.strength.feedback.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {passwordValidation.strength.feedback.map((feedback, index) => (
+ <div key={index} className="flex items-center space-x-2 text-xs">
+ <AlertCircle className="h-3 w-3 text-orange-500" />
+ <span className="text-orange-700">{feedback}</span>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 정책 오류 */}
+ {passwordValidation && !passwordValidation.policyValid && passwordValidation.policyErrors.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {passwordValidation.policyErrors.map((error, index) => (
+ <div key={index} className="flex items-center space-x-2 text-xs">
+ <XCircle className="h-3 w-3 text-red-500" />
+ <span className="text-red-700">{error}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ {/* 비밀번호 확인 */}
+ <div className="space-y-2">
+ <label htmlFor="confirmPassword" className="text-sm font-medium text-gray-700">
+ 비밀번호 확인
+ </label>
+ <div className="relative">
+ <Input
+ id="confirmPassword"
+ name="confirmPassword"
+ type={showConfirmPassword ? "text" : "password"}
+ value={confirmPassword}
+ onChange={(e) => setConfirmPassword(e.target.value)}
+ placeholder="비밀번호를 다시 입력하세요"
+ required
+ />
+ <button
+ type="button"
+ className="absolute inset-y-0 right-0 pr-3 flex items-center"
+ onClick={() => setShowConfirmPassword(!showConfirmPassword)}
+ >
+ {showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+ </button>
+ </div>
+
+ {/* 비밀번호 일치 확인 */}
+ {confirmPassword && (
+ <div className="flex items-center space-x-2 text-xs">
+ {passwordsMatch ? (
+ <>
+ <CheckCircle className="h-3 w-3 text-green-500" />
+ <span className="text-green-700">비밀번호가 일치합니다</span>
+ </>
+ ) : (
+ <>
+ <XCircle className="h-3 w-3 text-red-500" />
+ <span className="text-red-700">비밀번호가 일치하지 않습니다</span>
+ </>
+ )}
+ </div>
+ )}
+ </div>
+
+ <Button
+ type="submit"
+ className="w-full"
+ disabled={!canSubmit}
+ >
+ {isValidatingPassword ? '검증 중...' : '비밀번호 변경하기'}
+ </Button>
+ </form>
+
+ <div className="mt-6 text-center">
+ <Link href="/partners" className="text-sm text-blue-600 hover:text-blue-500">
+ 로그인 페이지로 돌아가기
+ </Link>
+ </div>
+ </CardContent>
+ </Card>
+ );
+} \ No newline at end of file