From 7b7ef5fec16eee98275c8b83d952c300ea925444 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 17:32:03 +0300 Subject: [PATCH] Password change as modal + admin can reset other user passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Modal.svelte component: overlay with backdrop click to close, title bar, reusable via children snippet - Layout: password change moved from inline sidebar form to modal dialog. Clean UX with current + new password fields. - Users page: ๐Ÿ”‘ button per user opens modal for admin to set a new password (no current password required for admin reset) - Backend: PUT /api/users/{id}/password (admin only, min 6 chars) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/components/Modal.svelte | 24 ++++++++++ frontend/src/routes/+layout.svelte | 37 ++++++++++------ frontend/src/routes/users/+page.svelte | 44 +++++++++++++++++-- .../src/immich_watcher_server/api/users.py | 23 ++++++++++ 4 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/components/Modal.svelte diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte new file mode 100644 index 0000000..c30fc70 --- /dev/null +++ b/frontend/src/lib/components/Modal.svelte @@ -0,0 +1,24 @@ + + +{#if open} + + +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {@render children()} +
+
+{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index d68efd7..0dd079a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import { getAuth, loadUser, logout } from '$lib/auth.svelte'; import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n'; import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte'; + import Modal from '$lib/components/Modal.svelte'; let { children } = $props(); const auth = getAuth(); @@ -173,22 +174,10 @@ {tt('nav.logout')} - - {#if showPasswordForm} -
- - - {#if pwdMsg}

{pwdMsg}

{/if} - -
- {/if} {/if} @@ -202,4 +191,26 @@ + + + { showPasswordForm = false; pwdMsg = ''; }}> +
+
+ + +
+
+ + +
+ {#if pwdMsg} +

{pwdMsg}

+ {/if} + +
+
{/if} diff --git a/frontend/src/routes/users/+page.svelte b/frontend/src/routes/users/+page.svelte index 7eb5ec5..efbc1e8 100644 --- a/frontend/src/routes/users/+page.svelte +++ b/frontend/src/routes/users/+page.svelte @@ -6,6 +6,7 @@ import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; + import Modal from '$lib/components/Modal.svelte'; const auth = getAuth(); let users = $state([]); @@ -14,6 +15,12 @@ let error = $state(''); let loaded = $state(false); + // Admin reset password + let resetUserId = $state(null); + let resetUsername = $state(''); + let resetPassword = $state(''); + let resetMsg = $state(''); + onMount(load); async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } } @@ -26,6 +33,17 @@ if (!confirm(t('users.confirmDelete'))) return; try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); } } + function openResetPassword(user: any) { + resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; + } + async function resetUserPassword(e: SubmitEvent) { + e.preventDefault(); resetMsg = ''; + try { + await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) }); + resetMsg = t('common.passwordChanged'); + setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000); + } catch (err: any) { resetMsg = err.message; } + } @@ -69,12 +87,32 @@

{user.username}

{user.role} ยท {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}

- {#if user.id !== auth.user?.id} - - {/if} +
+ {#if user.id !== auth.user?.id} + + + {/if} +
{/each} {/if} + + + { resetUserId = null; resetMsg = ''; }}> +
+
+ + +
+ {#if resetMsg} +

{resetMsg}

+ {/if} + +
+
diff --git a/packages/server/src/immich_watcher_server/api/users.py b/packages/server/src/immich_watcher_server/api/users.py index b05f73a..65387fb 100644 --- a/packages/server/src/immich_watcher_server/api/users.py +++ b/packages/server/src/immich_watcher_server/api/users.py @@ -62,6 +62,29 @@ async def create_user( return {"id": user.id, "username": user.username, "role": user.role} +class ResetPasswordRequest(BaseModel): + new_password: str + + +@router.put("/{user_id}/password") +async def reset_user_password( + user_id: int, + body: ResetPasswordRequest, + admin: User = Depends(require_admin), + session: AsyncSession = Depends(get_session), +): + """Reset a user's password (admin only).""" + user = await session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode() + session.add(user) + await session.commit() + return {"success": True} + + @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int,