From dfd837cfb0c84ed6abf7753732037c12c84690b5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 8 Dec 2025 10:19:34 -0600 Subject: [PATCH] refactor to use react-hook-form and zod (#21195) --- .../components/overlay/SetPasswordDialog.tsx | 782 ++++++++++-------- 1 file changed, 424 insertions(+), 358 deletions(-) diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index b66a5fa97..8d8ce37f3 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -1,6 +1,6 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, @@ -9,14 +9,23 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; - -import { Label } from "../ui/label"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../ui/form"; import { LuCheck, LuX, LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu"; import { useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; import useSWR from "swr"; import { formatSecondsToDuration } from "@/utils/dateUtil"; import ActivityIndicator from "../indicators/activity-indicator"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; type SetPasswordProps = { show: boolean; @@ -44,11 +53,6 @@ export default function SetPasswordDialog({ const refreshTimeLabel = refreshSeconds ? formatSecondsToDuration(refreshSeconds) : "30 minutes"; - const [oldPassword, setOldPassword] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordStrength, setPasswordStrength] = useState(0); - const [error, setError] = useState(null); // visibility toggles for password fields const [showOldPassword, setShowOldPassword] = useState(false); @@ -56,92 +60,136 @@ export default function SetPasswordDialog({ useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); - const [hasInitialized, setHasInitialized] = useState(false); - // Password strength requirements + // Create form schema with conditional old password requirement + const formSchema = useMemo(() => { + const baseSchema = { + password: z + .string() + .min(8, t("users.dialog.form.password.requirements.length")) + .regex(/[A-Z]/, t("users.dialog.form.password.requirements.uppercase")) + .regex(/\d/, t("users.dialog.form.password.requirements.digit")) + .regex( + /[!@#$%^&*(),.?":{}|<>]/, + t("users.dialog.form.password.requirements.special"), + ), + confirmPassword: z.string(), + }; - const requirements = { - length: password.length >= 8, - uppercase: /[A-Z]/.test(password), - digit: /\d/.test(password), - special: /[!@#$%^&*(),.?":{}|<>]/.test(password), - }; - - useEffect(() => { - if (show) { - if (!hasInitialized) { - setOldPassword(""); - setPassword(""); - setConfirmPassword(""); - setError(null); - setHasInitialized(true); - } + if (username) { + return z + .object({ + oldPassword: z + .string() + .min(1, t("users.dialog.passwordSetting.currentPasswordRequired")), + ...baseSchema, + }) + .refine((data) => data.password === data.confirmPassword, { + message: t("users.dialog.passwordSetting.doNotMatch"), + path: ["confirmPassword"], + }); } else { - setHasInitialized(false); + return z + .object(baseSchema) + .refine((data) => data.password === data.confirmPassword, { + message: t("users.dialog.passwordSetting.doNotMatch"), + path: ["confirmPassword"], + }); } - }, [show, hasInitialized]); + }, [username, t]); - useEffect(() => { - if (show && initialError) { - setError(initialError); - } - }, [show, initialError]); + type FormValues = z.infer; + + const defaultValues = username + ? { + oldPassword: "", + password: "", + confirmPassword: "", + } + : { + password: "", + confirmPassword: "", + }; + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: defaultValues as FormValues, + }); + + const password = form.watch("password"); + const confirmPassword = form.watch("confirmPassword"); // Password strength calculation - - useEffect(() => { - if (!password) { - setPasswordStrength(0); - return; - } + const passwordStrength = useMemo(() => { + if (!password) return 0; let strength = 0; - if (requirements.length) strength += 1; - if (requirements.digit) strength += 1; - if (requirements.special) strength += 1; - if (requirements.uppercase) strength += 1; + if (password.length >= 8) strength += 1; + if (/\d/.test(password)) strength += 1; + if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1; + if (/[A-Z]/.test(password)) strength += 1; - setPasswordStrength(strength); - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps + return strength; }, [password]); - const handleSave = async () => { - if (!password) { - setError(t("users.dialog.passwordSetting.cannotBeEmpty")); - return; - } + const requirements = useMemo( + () => ({ + length: password?.length >= 8, + uppercase: /[A-Z]/.test(password || ""), + digit: /\d/.test(password || ""), + special: /[!@#$%^&*(),.?":{}|<>]/.test(password || ""), + }), + [password], + ); - // Validate all requirements - if (!requirements.length) { - setError(t("users.dialog.form.password.requirements.length")); - return; - } - if (!requirements.uppercase) { - setError(t("users.dialog.form.password.requirements.uppercase")); - return; - } - if (!requirements.digit) { - setError(t("users.dialog.form.password.requirements.digit")); - return; - } - if (!requirements.special) { - setError(t("users.dialog.form.password.requirements.special")); - return; + // Reset form and visibility toggles when dialog opens/closes + useEffect(() => { + if (show) { + form.reset(); + setShowOldPassword(false); + setShowPasswordVisible(false); + setShowConfirmPassword(false); } + }, [show, form]); - if (password !== confirmPassword) { - setError(t("users.dialog.passwordSetting.doNotMatch")); - return; + // Handle backend errors + useEffect(() => { + if (show && initialError) { + const errorMsg = String(initialError); + // Check if the error is about incorrect current password + if ( + errorMsg.toLowerCase().includes("current password is incorrect") || + errorMsg.toLowerCase().includes("current password incorrect") + ) { + if (username) { + form.setError("oldPassword" as keyof FormValues, { + type: "manual", + message: t("users.dialog.passwordSetting.incorrectCurrentPassword"), + }); + } + } else { + // For other errors, show as form-level error + form.setError("root", { + type: "manual", + message: errorMsg, + }); + } } + }, [show, initialError, form, t, username]); - // Require old password when changing own password (username is provided) - if (username && !oldPassword) { - setError(t("users.dialog.passwordSetting.currentPasswordRequired")); - return; - } - - onSave(password, oldPassword || undefined); + const onSubmit = async (values: FormValues) => { + const oldPassword = + "oldPassword" in values + ? ( + values as { + oldPassword: string; + password: string; + confirmPassword: string; + } + ).oldPassword + : undefined; + onSave(values.password, oldPassword); }; const getStrengthLabel = () => { @@ -200,293 +248,311 @@ export default function SetPasswordDialog({

-
- {username && ( -
- -
- { - setOldPassword(event.target.value); - setError(null); - }} - placeholder={t( - "users.dialog.form.currentPassword.placeholder", - )} - /> - -
-
- )} - -
- -
- { - setPassword(event.target.value); - setError(null); - }} - placeholder={t("users.dialog.form.newPassword.placeholder")} - autoFocus - /> - -
- - {password && ( -
-
-
-
-

- {t("users.dialog.form.password.strength.title")} - {getStrengthLabel()} -

- -
-

- {t("users.dialog.form.password.requirements.title")} -

-
    -
  • - {requirements.length ? ( - - ) : ( - - )} - - {t("users.dialog.form.password.requirements.length")} - -
  • -
  • - {requirements.uppercase ? ( - - ) : ( - - )} - - {t("users.dialog.form.password.requirements.uppercase")} - -
  • -
  • - {requirements.digit ? ( - - ) : ( - - )} - - {t("users.dialog.form.password.requirements.digit")} - -
  • -
  • - {requirements.special ? ( - - ) : ( - - )} - - {t("users.dialog.form.password.requirements.special")} - -
  • -
-
-
- )} -
- -
- -
- { - setConfirmPassword(event.target.value); - setError(null); - }} - placeholder={t( - "users.dialog.form.newPassword.confirm.placeholder", +
+ + {username && ( + ( + + + {t("users.dialog.form.currentPassword.title")} + + +
+ + +
+
+ +
)} /> - -
+ )} - {/* Password match indicator */} - {password && confirmPassword && ( -
- {password === confirmPassword ? ( - <> - - - {t("users.dialog.form.password.match")} - - - ) : ( - <> - - - {t("users.dialog.form.password.notMatch")} - - - )} + ( + + + {t("users.dialog.form.newPassword.title")} + + +
+ + +
+
+ + {password && ( +
+
+
+
+

+ {t("users.dialog.form.password.strength.title")} + + {getStrengthLabel()} + +

+ +
+

+ {t("users.dialog.form.password.requirements.title")} +

+
    +
  • + {requirements.length ? ( + + ) : ( + + )} + + {t( + "users.dialog.form.password.requirements.length", + )} + +
  • +
  • + {requirements.uppercase ? ( + + ) : ( + + )} + + {t( + "users.dialog.form.password.requirements.uppercase", + )} + +
  • +
  • + {requirements.digit ? ( + + ) : ( + + )} + + {t( + "users.dialog.form.password.requirements.digit", + )} + +
  • +
  • + {requirements.special ? ( + + ) : ( + + )} + + {t( + "users.dialog.form.password.requirements.special", + )} + +
  • +
+
+
+ )} + + + + )} + /> + + ( + + + {t("users.dialog.form.password.confirm.title")} + + +
+ + +
+
+ + {password && + confirmPassword && + password === confirmPassword && ( +
+ + + {t("users.dialog.form.password.match")} + +
+ )} + + +
+ )} + /> + + {form.formState.errors.root && ( +
+ {form.formState.errors.root.message}
)} -
- {error && ( -
- {error} -
- )} -
- - -
-
- - -
-
-
+ +
+
+ + +
+
+
+ + );