Password change as modal + admin can reset other user passwords
Some checks failed
Validate / Hassfest (push) Has been cancelled

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 17:32:03 +03:00
parent 0200b9929f
commit 7b7ef5fec1
4 changed files with 112 additions and 16 deletions

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { t } from '$lib/i18n';
let { open = false, title = '', onclose, children } = $props<{
open: boolean;
title?: string;
onclose: () => void;
children: import('svelte').Snippet;
}>();
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={onclose}>
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg w-full max-w-md mx-4 p-5"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">{title}</h3>
<button onclick={onclose} class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] text-lg leading-none">&times;</button>
</div>
{@render children()}
</div>
</div>
{/if}

View File

@@ -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')}
</button>
</div>
<button onclick={() => showPasswordForm = !showPasswordForm}
<button onclick={() => showPasswordForm = true}
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
🔑 {tt('common.changePassword')}
</button>
{#if showPasswordForm}
<form onsubmit={changePassword} class="mt-2 space-y-2">
<input type="password" bind:value={pwdCurrent} required placeholder={tt('common.currentPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
<input type="password" bind:value={pwdNew} required placeholder={tt('common.newPassword')}
class="w-full px-2 py-1 text-xs border border-[var(--color-border)] rounded bg-[var(--color-background)]" />
{#if pwdMsg}<p class="text-xs text-[var(--color-muted-foreground)]">{pwdMsg}</p>{/if}
<button type="submit" class="w-full px-2 py-1 text-xs bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90">
{tt('common.save')}
</button>
</form>
{/if}
</div>
{/if}
</div>
@@ -202,4 +191,26 @@
</div>
</main>
</div>
<!-- Password change modal -->
<Modal open={showPasswordForm} title={tt('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; }}>
<form onsubmit={changePassword} class="space-y-3">
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{tt('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{tt('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
<p class="text-sm {pwdMsg.includes(tt('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{pwdMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{tt('common.save')}
</button>
</form>
</Modal>
{/if}

View File

@@ -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<any[]>([]);
@@ -14,6 +15,12 @@
let error = $state('');
let loaded = $state(false);
// Admin reset password
let resetUserId = $state<number | null>(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; }
}
</script>
<PageHeader title={t('users.title')} description={t('users.description')}>
@@ -69,12 +87,32 @@
<p class="font-medium">{user.username}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
</div>
{#if user.id !== auth.user?.id}
<button onclick={() => remove(user.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('users.delete')}</button>
{/if}
<div class="flex items-center gap-3">
{#if user.id !== auth.user?.id}
<button onclick={() => openResetPassword(user)} class="text-xs text-[var(--color-muted-foreground)] hover:underline">🔑</button>
<button onclick={() => remove(user.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('users.delete')}</button>
{/if}
</div>
</div>
</Card>
{/each}
</div>
{/if}
<!-- Admin reset password modal -->
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; }}>
<form onsubmit={resetUserPassword} class="space-y-3">
<div>
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="reset-pwd" type="password" bind:value={resetPassword} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if resetMsg}
<p class="text-sm {resetMsg.includes(t('common.passwordChanged')) ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
{/if}
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{t('common.save')}
</button>
</form>
</Modal>

View File

@@ -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,