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")}
-
-
-
-
-
- )}
-
-
-
-
-
+ )}
- {/* 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}
-
- )}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);