"use client"; import { useState, forwardRef, useImperativeHandle, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { CheckCircle2, Check, X } from "lucide-react"; import { Progress } from "@/components/ui/progress"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { AxiosResponse } from "axios"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { useTranslations } from "next-intl"; import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { ChangePasswordResponse } from "@server/routers/auth"; import { cn } from "@app/lib/cn"; // Password strength calculation const calculatePasswordStrength = (password: string) => { const requirements = { length: password.length >= 8, uppercase: /[A-Z]/.test(password), lowercase: /[a-z]/.test(password), number: /[0-9]/.test(password), special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password) }; const score = Object.values(requirements).filter(Boolean).length; let strength: "weak" | "medium" | "strong" = "weak"; let color = "bg-red-500"; let percentage = 0; if (score >= 5) { strength = "strong"; color = "bg-green-500"; percentage = 100; } else if (score >= 3) { strength = "medium"; color = "bg-yellow-500"; percentage = 60; } else if (score >= 1) { strength = "weak"; color = "bg-red-500"; percentage = 30; } return { requirements, strength, color, percentage, score }; }; type ChangePasswordFormProps = { onComplete?: () => void; onCancel?: () => void; isDialog?: boolean; submitButtonText?: string; cancelButtonText?: string; showCancelButton?: boolean; onStepChange?: (step: number) => void; onLoadingChange?: (loading: boolean) => void; }; const ChangePasswordForm = forwardRef< { handleSubmit: () => void }, ChangePasswordFormProps >( ( { onComplete, onCancel, isDialog = false, submitButtonText, cancelButtonText, showCancelButton = false, onStepChange, onLoadingChange }, ref ) => { const [step, setStep] = useState(1); const [loading, setLoading] = useState(false); const [newPasswordValue, setNewPasswordValue] = useState(""); const [confirmPasswordValue, setConfirmPasswordValue] = useState(""); const api = createApiClient(useEnvContext()); const t = useTranslations(); const passwordStrength = calculatePasswordStrength(newPasswordValue); const doPasswordsMatch = newPasswordValue.length > 0 && confirmPasswordValue.length > 0 && newPasswordValue === confirmPasswordValue; // Notify parent of step and loading changes useEffect(() => { onStepChange?.(step); }, [step, onStepChange]); useEffect(() => { onLoadingChange?.(loading); }, [loading, onLoadingChange]); const passwordSchema = z.object({ oldPassword: z.string().min(1, { message: t("passwordRequired") }), newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }), confirmPassword: z.string().min(1, { message: t("passwordRequired") }) }).refine((data) => data.newPassword === data.confirmPassword, { message: t("passwordsDoNotMatch"), path: ["confirmPassword"], }); const mfaSchema = z.object({ code: z.string().length(6, { message: t("pincodeInvalid") }) }); const passwordForm = useForm({ resolver: zodResolver(passwordSchema), defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" } }); const mfaForm = useForm({ resolver: zodResolver(mfaSchema), defaultValues: { code: "" } }); const changePassword = async (values: z.infer) => { setLoading(true); const endpoint = `/auth/change-password`; const payload = { oldPassword: values.oldPassword, newPassword: values.newPassword }; const res = await api .post>(endpoint, payload) .catch((e) => { toast({ title: t("changePasswordError"), description: formatAxiosError( e, t("changePasswordErrorDescription") ), variant: "destructive" }); }); if (res && res.data) { if (res.data.data?.codeRequested) { setStep(2); } else { setStep(3); } } setLoading(false); }; const confirmMfa = async (values: z.infer) => { setLoading(true); const endpoint = `/auth/change-password`; const passwordValues = passwordForm.getValues(); const payload = { oldPassword: passwordValues.oldPassword, newPassword: passwordValues.newPassword, code: values.code }; const res = await api .post>(endpoint, payload) .catch((e) => { toast({ title: t("changePasswordError"), description: formatAxiosError( e, t("changePasswordErrorDescription") ), variant: "destructive" }); }); if (res && res.data) { setStep(3); } setLoading(false); }; const handleSubmit = () => { if (step === 1) { passwordForm.handleSubmit(changePassword)(); } else if (step === 2) { mfaForm.handleSubmit(confirmMfa)(); } }; const handleComplete = () => { if (onComplete) { onComplete(); } }; useImperativeHandle(ref, () => ({ handleSubmit })); return (
{step === 1 && (
( {t("oldPassword")} )} /> (
{t("newPassword")} {passwordStrength.strength === "strong" && ( )}
{ field.onChange(e); setNewPasswordValue( e.target.value ); }} className={cn( passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500", passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500", passwordStrength.strength === "weak" && newPasswordValue.length > 0 && "border-red-500 focus-visible:ring-red-500" )} autoComplete="new-password" />
{newPasswordValue.length > 0 && (
{/* Password Strength Meter */}
{t("passwordStrength")} {t( `passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}` )}
{/* Requirements Checklist */}
{t("passwordRequirements")}
{passwordStrength .requirements .length ? ( ) : ( )} {t( "passwordRequirementLengthText" )}
{passwordStrength .requirements .uppercase ? ( ) : ( )} {t( "passwordRequirementUppercaseText" )}
{passwordStrength .requirements .lowercase ? ( ) : ( )} {t( "passwordRequirementLowercaseText" )}
{passwordStrength .requirements .number ? ( ) : ( )} {t( "passwordRequirementNumberText" )}
{passwordStrength .requirements .special ? ( ) : ( )} {t( "passwordRequirementSpecialText" )}
)} {/* Only show FormMessage when not showing our custom requirements */} {newPasswordValue.length === 0 && ( )}
)} /> (
{t("confirmNewPassword")} {doPasswordsMatch && ( )}
{ field.onChange(e); setConfirmPasswordValue( e.target.value ); }} className={cn( doPasswordsMatch && "border-green-500 focus-visible:ring-green-500", confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500" )} autoComplete="new-password" />
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (

{t("passwordsDoNotMatch")}

)} {/* Only show FormMessage when field is empty */} {confirmPasswordValue.length === 0 && ( )}
)} />
)} {step === 2 && (

{t("otpAuth")}

{t("otpAuthDescription")}

(
{ field.onChange(value); if ( value.length === 6 ) { mfaForm.handleSubmit( confirmMfa )(); } }} >
)} />
)} {step === 3 && (

{t("changePasswordSuccess")}

{t("changePasswordSuccessDescription")}

)} {/* Action buttons - only show when not in dialog */} {!isDialog && (
{showCancelButton && onCancel && ( )} {(step === 1 || step === 2) && ( )} {step === 3 && ( )}
)}
); } ); export default ChangePasswordForm;