diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 6dde92fbe..5fae34a78 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -123,7 +123,7 @@ auth: # Optional: Refresh time in seconds (default: shown below) # When the session is going to expire in less time than this setting, # it will be refreshed back to the session_length. - refresh_time: 43200 # 12 hours + refresh_time: 1800 # 30 minutes # Optional: Rate limiting for login failures to help prevent brute force # login attacks (default: shown below) # See the docs for more information on valid values diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 9d921ab6a..d913173d0 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -55,8 +55,8 @@ def require_admin_by_default(): "/auth", "/auth/first_time_login", "/login", - # Authenticated user endpoints (allow_any_authenticated) "/logout", + # Authenticated user endpoints (allow_any_authenticated) "/profile", # Public info endpoints (allow_public) "/", @@ -311,7 +311,10 @@ def get_jwt_secret() -> str: ) jwt_secret = secrets.token_hex(64) try: - with open(jwt_secret_file, "w") as f: + fd = os.open( + jwt_secret_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + with os.fdopen(fd, "w") as f: f.write(str(jwt_secret)) except Exception: logger.warning( @@ -356,9 +359,35 @@ def verify_password(password, password_hash): return secrets.compare_digest(password_hash, compare_hash) +def validate_password_strength(password: str) -> tuple[bool, Optional[str]]: + """ + Validate password strength. + + Returns a tuple of (is_valid, error_message). + """ + if not password: + return False, "Password cannot be empty" + + if len(password) < 8: + return False, "Password must be at least 8 characters long" + + if not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if not any(c.isdigit() for c in password): + return False, "Password must contain at least one digit" + + if not any(c in '!@#$%^&*(),.?":{}|<>' for c in password): + return False, "Password must contain at least one special character" + + return True, None + + def create_encoded_jwt(user, role, expiration, secret): return jwt.encode( - {"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret + {"alg": "HS256"}, + {"sub": user, "role": role, "exp": expiration, "iat": int(time.time())}, + secret, ) @@ -619,13 +648,27 @@ def auth(request: Request): return fail_response # if the jwt cookie is expiring soon - elif jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time: + if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time: logger.debug("jwt token expiring soon, refreshing cookie") - # ensure the user hasn't been deleted + + # Check if password has been changed since token was issued + # If so, force re-login by rejecting the refresh try: - User.get_by_id(user) + user_obj = User.get_by_id(user) + if user_obj.password_changed_at is not None: + token_iat = int(token.claims.get("iat", 0)) + password_changed_timestamp = int( + user_obj.password_changed_at.timestamp() + ) + if token_iat < password_changed_timestamp: + logger.debug( + "jwt token issued before password change, rejecting refresh" + ) + return fail_response except DoesNotExist: + logger.debug("user not found") return fail_response + new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( user, role, new_expiration, request.app.jwt_token @@ -660,7 +703,7 @@ def profile(request: Request): ) -@router.get("/logout", dependencies=[Depends(allow_any_authenticated())]) +@router.get("/logout", dependencies=[Depends(allow_public())]) def logout(request: Request): auth_config: AuthConfig = request.app.frigate_config.auth response = RedirectResponse("/login", status_code=303) @@ -782,10 +825,63 @@ async def update_password( HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations - password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) - User.set_by_id(username, {User.password_hash: password_hash}) + try: + user = User.get_by_id(username) + except DoesNotExist: + return JSONResponse(content={"message": "User not found"}, status_code=404) - return JSONResponse(content={"success": True}) + # Require old_password when: + # 1. Non-admin user is changing another user's password (admin only action) + # 2. Any user is changing their own password + is_changing_own_password = current_username == username + is_non_admin = current_role != "admin" + + if is_changing_own_password or is_non_admin: + if not body.old_password: + return JSONResponse( + content={"message": "Current password is required"}, + status_code=400, + ) + if not verify_password(body.old_password, user.password_hash): + return JSONResponse( + content={"message": "Current password is incorrect"}, + status_code=401, + ) + + # Validate new password strength + is_valid, error_message = validate_password_strength(body.password) + if not is_valid: + return JSONResponse( + content={"message": error_message}, + status_code=400, + ) + + password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) + User.update( + { + User.password_hash: password_hash, + User.password_changed_at: datetime.now(), + } + ).where(User.username == username).execute() + + response = JSONResponse(content={"success": True}) + + # If user changed their own password, issue a new JWT to keep them logged in + if current_username == username: + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name + JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure + JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length + + expiration = int(time.time()) + JWT_SESSION_LENGTH + encoded_jwt = create_encoded_jwt( + username, current_role, expiration, request.app.jwt_token + ) + # Set new JWT cookie on response + set_jwt_cookie( + response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE + ) + + return response @router.put( diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 7f8ca40ec..c4129d8da 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -11,6 +11,7 @@ class AppConfigSetBody(BaseModel): class AppPutPasswordBody(BaseModel): password: str + old_password: Optional[str] = None class AppPostUsersBody(BaseModel): diff --git a/frigate/config/auth.py b/frigate/config/auth.py index fced20620..6935350a0 100644 --- a/frigate/config/auth.py +++ b/frigate/config/auth.py @@ -20,7 +20,7 @@ class AuthConfig(FrigateBaseModel): default=86400, title="Session length for jwt session tokens", ge=60 ) refresh_time: int = Field( - default=43200, + default=1800, title="Refresh the session if it is going to expire in this many seconds", ge=30, ) diff --git a/frigate/models.py b/frigate/models.py index 59188128b..93f6cb54f 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -133,6 +133,7 @@ class User(Model): default="admin", ) password_hash = CharField(null=False, max_length=120) + password_changed_at = DateTimeField(null=True) notification_tokens = JSONField() @classmethod diff --git a/migrations/030_create_user_review_status.py b/migrations/030_create_user_review_status.py index 17f2b36b9..38937f7f9 100644 --- a/migrations/030_create_user_review_status.py +++ b/migrations/030_create_user_review_status.py @@ -54,7 +54,9 @@ def migrate(migrator, database, fake=False, **kwargs): # Migrate existing has_been_reviewed data to UserReviewStatus for all users def migrate_data(): - all_users = list(User.select()) + # Use raw SQL to avoid ORM issues with columns that don't exist yet + cursor = database.execute_sql('SELECT "username" FROM "user"') + all_users = cursor.fetchall() if not all_users: return @@ -63,7 +65,7 @@ def migrate(migrator, database, fake=False, **kwargs): ) reviewed_segment_ids = [row[0] for row in cursor.fetchall()] # also migrate for anonymous (unauthenticated users) - usernames = [user.username for user in all_users] + ["anonymous"] + usernames = [user[0] for user in all_users] + ["anonymous"] for segment_id in reviewed_segment_ids: for username in usernames: diff --git a/migrations/032_add_password_changed_at.py b/migrations/032_add_password_changed_at.py new file mode 100644 index 000000000..eedb3ab2d --- /dev/null +++ b/migrations/032_add_password_changed_at.py @@ -0,0 +1,42 @@ +"""Peewee migrations -- 032_add_password_changed_at.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + """ + ALTER TABLE user ADD COLUMN password_changed_at DATETIME NULL + """ + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.sql( + """ + ALTER TABLE user DROP COLUMN password_changed_at + """ + ) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7ffd29007..cc3a1b727 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -712,6 +712,8 @@ "password": { "title": "Password", "placeholder": "Enter password", + "show": "Show password", + "hide": "Hide password", "confirm": { "title": "Confirm Password", "placeholder": "Confirm Password" @@ -723,6 +725,13 @@ "strong": "Strong", "veryStrong": "Very Strong" }, + "requirements": { + "title": "Password requirements:", + "length": "At least 8 characters", + "uppercase": "At least one uppercase letter", + "digit": "At least one digit", + "special": "At least one special character (!@#$%^&*(),.?\":{}|<>)" + }, "match": "Passwords match", "notMatch": "Passwords don't match" }, @@ -733,6 +742,10 @@ "placeholder": "Re-enter new password" } }, + "currentPassword": { + "title": "Current Password", + "placeholder": "Enter your current password" + }, "usernameIsRequired": "Username is required", "passwordIsRequired": "Password is required" }, @@ -750,9 +763,13 @@ "passwordSetting": { "cannotBeEmpty": "Password cannot be empty", "doNotMatch": "Passwords do not match", + "currentPasswordRequired": "Current password is required", + "incorrectCurrentPassword": "Current password is incorrect", + "passwordVerificationFailed": "Failed to verify password", "updatePassword": "Update Password for {{username}}", "setPassword": "Set Password", - "desc": "Create a strong password to secure this account." + "desc": "Create a strong password to secure this account.", + "multiDeviceWarning": "Any other devices where you are logged in will be required to re-login within {{refresh_time}}. You can also force all users to re-authenticate immediately by rotating your JWT secret." }, "changeRole": { "title": "Change User Role", diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 723aa0ccc..e5a367391 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -42,19 +42,27 @@ export default function AccountSettings({ className }: AccountSettingsProps) { const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [isPasswordLoading, setIsPasswordLoading] = useState(false); const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; const MenuItem = isDesktop ? DropdownMenuItem : DrawerClose; - const handlePasswordSave = async (password: string) => { + const handlePasswordSave = async (password: string, oldPassword?: string) => { if (!profile?.username || profile.username === "anonymous") return; + setIsPasswordLoading(true); axios - .put(`users/${profile.username}/password`, { password }) + .put(`users/${profile.username}/password`, { + password, + old_password: oldPassword, + }) .then((response) => { if (response.status === 200) { setPasswordDialogOpen(false); + setPasswordError(null); + setIsPasswordLoading(false); toast.success(t("users.toast.success.updatePassword"), { position: "top-center", }); @@ -65,14 +73,10 @@ export default function AccountSettings({ className }: AccountSettingsProps) { error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error( - t("users.toast.error.setPasswordFailed", { - errorMessage, - }), - { - position: "top-center", - }, - ); + + // Keep dialog open and show error + setPasswordError(errorMessage); + setIsPasswordLoading(false); }); }; @@ -154,8 +158,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) { setPasswordDialogOpen(false)} + onCancel={() => { + setPasswordDialogOpen(false); + setPasswordError(null); + }} + initialError={passwordError} username={profile?.username} + isLoading={isPasswordLoading} /> ); diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 16c6eb9f8..1788bce84 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -116,13 +116,22 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; - const handlePasswordSave = async (password: string) => { + const [passwordError, setPasswordError] = useState(null); + const [isPasswordLoading, setIsPasswordLoading] = useState(false); + + const handlePasswordSave = async (password: string, oldPassword?: string) => { if (!profile?.username || profile.username === "anonymous") return; + setIsPasswordLoading(true); axios - .put(`users/${profile.username}/password`, { password }) + .put(`users/${profile.username}/password`, { + password, + old_password: oldPassword, + }) .then((response) => { if (response.status === 200) { setPasswordDialogOpen(false); + setPasswordError(null); + setIsPasswordLoading(false); toast.success( t("users.toast.success.updatePassword", { ns: "views/settings", @@ -138,15 +147,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error( - t("users.toast.error.setPasswordFailed", { - ns: "views/settings", - errorMessage, - }), - { - position: "top-center", - }, - ); + + // Keep dialog open and show error + setPasswordError(errorMessage); + setIsPasswordLoading(false); }); }; @@ -554,8 +558,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { setPasswordDialogOpen(false)} + onCancel={() => { + setPasswordDialogOpen(false); + setPasswordError(null); + }} + initialError={passwordError} username={profile?.username} + isLoading={isPasswordLoading} /> ); diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 58a88fa08..b66a5fa97 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,38 +11,84 @@ import { } from "../ui/dialog"; import { Label } from "../ui/label"; -import { LuCheck, LuX } from "react-icons/lu"; +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"; type SetPasswordProps = { show: boolean; - onSave: (password: string) => void; + onSave: (password: string, oldPassword?: string) => void; onCancel: () => void; + initialError?: string | null; username?: string; + isLoading?: boolean; }; export default function SetPasswordDialog({ show, onSave, onCancel, + initialError, username, + isLoading = false, }: SetPasswordProps) { - const { t } = useTranslation(["views/settings"]); + const { t } = useTranslation(["views/settings", "common"]); + const { getLocaleDocUrl } = useDocDomain(); + + const { data: config } = useSWR("config"); + const refreshSeconds: number | undefined = + config?.auth?.refresh_time ?? undefined; + 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); - // Reset state when dialog opens/closes + // visibility toggles for password fields + const [showOldPassword, setShowOldPassword] = useState(false); + const [showPasswordVisible, setShowPasswordVisible] = + useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + const [hasInitialized, setHasInitialized] = useState(false); + + // Password strength requirements + + const requirements = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + digit: /\d/.test(password), + special: /[!@#$%^&*(),.?":{}|<>]/.test(password), + }; + useEffect(() => { if (show) { - setPassword(""); - setConfirmPassword(""); - setError(null); + if (!hasInitialized) { + setOldPassword(""); + setPassword(""); + setConfirmPassword(""); + setError(null); + setHasInitialized(true); + } + } else { + setHasInitialized(false); } - }, [show]); + }, [show, hasInitialized]); + + useEffect(() => { + if (show && initialError) { + setError(initialError); + } + }, [show, initialError]); + + // Password strength calculation - // Simple password strength calculation useEffect(() => { if (!password) { setPasswordStrength(0); @@ -52,30 +96,52 @@ 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; + } + + onSave(password, oldPassword || undefined); }; const getStrengthLabel = () => { @@ -112,39 +178,199 @@ export default function SetPasswordDialog({ {t("users.dialog.passwordSetting.desc")} + +

+ {t("users.dialog.passwordSetting.multiDeviceWarning", { + refresh_time: refreshTimeLabel, + ns: "views/settings", + })} +

+

+ + {t("readTheDocumentation", { ns: "common" })} + + +

+ {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 +379,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 && ( @@ -204,6 +455,7 @@ export default function SetPasswordDialog({ aria-label={t("button.cancel", { ns: "common" })} onClick={onCancel} type="button" + disabled={isLoading} > {t("button.cancel", { ns: "common" })} @@ -212,9 +464,25 @@ export default function SetPasswordDialog({ aria-label={t("button.save", { ns: "common" })} className="flex flex-1" onClick={handleSave} - disabled={!password || password !== confirmPassword} + disabled={ + isLoading || + !password || + password !== confirmPassword || + (username && !oldPassword) || + !requirements.length || + !requirements.uppercase || + !requirements.digit || + !requirements.special + } > - {t("button.save", { ns: "common" })} + {isLoading ? ( +
+ + {t("button.saving", { ns: "common" })} +
+ ) : ( + t("button.save", { ns: "common" }) + )}
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 124348813..225de37f8 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -57,6 +57,8 @@ export default function AuthenticationView({ const [showCreateRole, setShowCreateRole] = useState(false); const [showEditRole, setShowEditRole] = useState(false); const [showDeleteRole, setShowDeleteRole] = useState(false); + const [passwordError, setPasswordError] = useState(null); + const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [selectedUser, setSelectedUser] = useState(); const [selectedUserRole, setSelectedUserRole] = useState(); @@ -70,12 +72,15 @@ export default function AuthenticationView({ }, [t]); const onSavePassword = useCallback( - (user: string, password: string) => { + (user: string, password: string, oldPassword?: string) => { + setIsPasswordLoading(true); axios - .put(`users/${user}/password`, { password }) + .put(`users/${user}/password`, { password, old_password: oldPassword }) .then((response) => { if (response.status === 200) { setShowSetPassword(false); + setPasswordError(null); + setIsPasswordLoading(false); toast.success(t("users.toast.success.updatePassword"), { position: "top-center", }); @@ -86,14 +91,10 @@ export default function AuthenticationView({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error( - t("users.toast.error.setPasswordFailed", { - errorMessage, - }), - { - position: "top-center", - }, - ); + + // Keep dialog open and show error + setPasswordError(errorMessage); + setIsPasswordLoading(false); }); }, [t], @@ -563,8 +564,15 @@ export default function AuthenticationView({
setShowSetPassword(false)} - onSave={(password) => onSavePassword(selectedUser!, password)} + onCancel={() => { + setShowSetPassword(false); + setPasswordError(null); + }} + initialError={passwordError} + onSave={(password, oldPassword) => + onSavePassword(selectedUser!, password, oldPassword) + } + isLoading={isPasswordLoading} />