From b05a7ccd660bf86027f975b203d07de8b437084c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 7 Dec 2025 19:55:56 -0600 Subject: [PATCH] improve set password dialog - add field to verify old password - add password strength requirements --- .../components/overlay/SetPasswordDialog.tsx | 340 +++++++++++++++--- 1 file changed, 293 insertions(+), 47 deletions(-) diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 58a88fa08..b8a3b9d86 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { useState, useEffect } from "react"; @@ -13,13 +11,16 @@ import { } from "../ui/dialog"; import { Label } from "../ui/label"; -import { LuCheck, LuX } from "react-icons/lu"; +import { LuCheck, LuX, LuEye, LuEyeOff } from "react-icons/lu"; import { useTranslation } from "react-i18next"; +import ActivityIndicator from "../indicators/activity-indicator"; type SetPasswordProps = { show: boolean; - onSave: (password: string) => void; + onSave: (password: string, oldPassword?: string) => void; onCancel: () => void; + onVerifyOldPassword?: (oldPassword: string) => Promise; + initialError?: string | null; username?: string; }; @@ -27,24 +28,48 @@ export default function SetPasswordDialog({ show, onSave, onCancel, + onVerifyOldPassword, + initialError, username, }: SetPasswordProps) { const { t } = useTranslation(["views/settings"]); + const [oldPassword, setOldPassword] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordStrength, setPasswordStrength] = useState(0); const [error, setError] = useState(null); + const [isValidatingOldPassword, setIsValidatingOldPassword] = + useState(false); + + // visibility toggles for password fields + const [showOldPassword, setShowOldPassword] = useState(false); + const [showPasswordVisible, setShowPasswordVisible] = + useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + + // Password strength requirements + + const requirements = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + digit: /\d/.test(password), + special: /[!@#$%^&*(),.?":{}|<>]/.test(password), + }; // Reset state when dialog opens/closes + useEffect(() => { if (show) { + setOldPassword(""); setPassword(""); setConfirmPassword(""); - setError(null); + setError(initialError || null); } - }, [show]); + }, [show, initialError]); + + // Password strength calculation - // Simple password strength calculation useEffect(() => { if (!password) { setPasswordStrength(0); @@ -52,30 +77,70 @@ export default function SetPasswordDialog({ } let strength = 0; - // Length check - if (password.length >= 8) strength += 1; - // Contains number - if (/\d/.test(password)) strength += 1; - // Contains special char - if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1; - // Contains uppercase - if (/[A-Z]/.test(password)) strength += 1; + if (requirements.length) strength += 1; + if (requirements.digit) strength += 1; + if (requirements.special) strength += 1; + if (requirements.uppercase) strength += 1; setPasswordStrength(strength); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps }, [password]); - const handleSave = () => { + const handleSave = async () => { if (!password) { setError(t("users.dialog.passwordSetting.cannotBeEmpty")); return; } + // 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; + } + if (password !== confirmPassword) { setError(t("users.dialog.passwordSetting.doNotMatch")); return; } - onSave(password); + // Require old password when changing own password (username is provided) + if (username && !oldPassword) { + setError(t("users.dialog.passwordSetting.currentPasswordRequired")); + return; + } + + // Verify old password if callback is provided and old password is provided + if (username && oldPassword && onVerifyOldPassword) { + setIsValidatingOldPassword(true); + try { + const isValid = await onVerifyOldPassword(oldPassword); + if (!isValid) { + setError(t("users.dialog.passwordSetting.incorrectCurrentPassword")); + setIsValidatingOldPassword(false); + return; + } + } catch (err) { + setError(t("users.dialog.passwordSetting.passwordVerificationFailed")); + setIsValidatingOldPassword(false); + return; + } + setIsValidatingOldPassword(false); + } + + onSave(password, oldPassword || undefined); }; const getStrengthLabel = () => { @@ -115,36 +180,176 @@ 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 - /> +
+ { + setPassword(event.target.value); + setError(null); + }} + placeholder={t("users.dialog.form.newPassword.placeholder")} + autoFocus + /> + +
- {/* Password strength indicator */} {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")} + +
  • +
+
)}
@@ -153,19 +358,44 @@ export default function SetPasswordDialog({ - { - setConfirmPassword(event.target.value); - setError(null); - }} - placeholder={t( - "users.dialog.form.newPassword.confirm.placeholder", - )} - /> +
+ { + setConfirmPassword(event.target.value); + setError(null); + }} + placeholder={t( + "users.dialog.form.newPassword.confirm.placeholder", + )} + /> + +
{/* Password match indicator */} {password && confirmPassword && ( @@ -212,9 +442,25 @@ export default function SetPasswordDialog({ aria-label={t("button.save", { ns: "common" })} className="flex flex-1" onClick={handleSave} - disabled={!password || password !== confirmPassword} + disabled={ + isValidatingOldPassword || + !password || + password !== confirmPassword || + (username && !oldPassword) || + !requirements.length || + !requirements.uppercase || + !requirements.digit || + !requirements.special + } > - {t("button.save", { ns: "common" })} + {isValidatingOldPassword ? ( +
+ + {t("button.saving", { ns: "common" })} +
+ ) : ( + t("button.save", { ns: "common" }) + )}