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 { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||||
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
@@ -173,22 +174,10 @@
|
|||||||
{tt('nav.logout')}
|
{tt('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => showPasswordForm = !showPasswordForm}
|
<button onclick={() => showPasswordForm = true}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
||||||
🔑 {tt('common.changePassword')}
|
🔑 {tt('common.changePassword')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -202,4 +191,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
let users = $state<any[]>([]);
|
let users = $state<any[]>([]);
|
||||||
@@ -14,6 +15,12 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
// Admin reset password
|
||||||
|
let resetUserId = $state<number | null>(null);
|
||||||
|
let resetUsername = $state('');
|
||||||
|
let resetPassword = $state('');
|
||||||
|
let resetMsg = $state('');
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
|
async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } }
|
||||||
|
|
||||||
@@ -26,6 +33,17 @@
|
|||||||
if (!confirm(t('users.confirmDelete'))) return;
|
if (!confirm(t('users.confirmDelete'))) return;
|
||||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); }
|
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>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('users.title')} description={t('users.description')}>
|
<PageHeader title={t('users.title')} description={t('users.description')}>
|
||||||
@@ -69,12 +87,32 @@
|
|||||||
<p class="font-medium">{user.username}</p>
|
<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>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if user.id !== auth.user?.id}
|
<div class="flex items-center gap-3">
|
||||||
<button onclick={() => remove(user.id)} class="text-xs text-[var(--color-destructive)] hover:underline">{t('users.delete')}</button>
|
{#if user.id !== auth.user?.id}
|
||||||
{/if}
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/if}
|
{/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}
|
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)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
Reference in New Issue
Block a user