diff options
Diffstat (limited to 'components/login')
| -rw-r--r-- | components/login/InvalidTokenPage.tsx | 45 | ||||
| -rw-r--r-- | components/login/SuccessPage.tsx | 53 | ||||
| -rw-r--r-- | components/login/login-form copy.tsx | 485 | ||||
| -rw-r--r-- | components/login/login-form.tsx | 814 | ||||
| -rw-r--r-- | components/login/next-auth-reauth-modal.tsx | 215 | ||||
| -rw-r--r-- | components/login/partner-auth-form.tsx | 16 | ||||
| -rw-r--r-- | components/login/privacy-policy-page.tsx | 733 | ||||
| -rw-r--r-- | components/login/reset-password.tsx | 351 |
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">“{t("blockquote")}”</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">“{t("blockquote")}”</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 |
