diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-27 01:16:20 +0000 |
| commit | e9897d416b3e7327bbd4d4aef887eee37751ae82 (patch) | |
| tree | bd20ce6eadf9b21755bd7425492d2d31c7700a0e /components/login/reset-password.tsx | |
| parent | 3bf1952c1dad9d479bb8b22031b06a7434d37c37 (diff) | |
(대표님) 20250627 오전 10시 작업사항
Diffstat (limited to 'components/login/reset-password.tsx')
| -rw-r--r-- | components/login/reset-password.tsx | 351 |
1 files changed, 351 insertions, 0 deletions
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 |
