Password change as modal + admin can reset other user passwords
Some checks failed
Validate / Hassfest (push) Has been cancelled
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:
24
frontend/src/lib/components/Modal.svelte
Normal file
24
frontend/src/lib/components/Modal.svelte
Normal 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">×</button>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user