Files
minstrel/web/src/routes/admin/users/+page.svelte
T
bvandeusen 1536860e59 fix(web): move {@const} blocks to immediate children of {#each}
Svelte 5 requires {@const} to be a direct child of certain blocks
({#snippet}, {#if}, {#each}, etc.). Placing them inside <li>
bodies broke the build with const_tag_invalid_placement. Moved
the RowAction const declarations up so they sit between {#each}
and the <li> opener; the each-binding (u/r) is in scope for the
entire each block body, so the consts behave identically.

Caught by local docker build on dev:
  src/routes/admin/users/+page.svelte:259:12
  https://svelte.dev/e/const_tag_invalid_placement

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 20:26:04 -04:00

505 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { pageTitle } from '$lib/branding';
import { useQueryClient } from '@tanstack/svelte-query';
import { qk } from '$lib/api/queries';
import {
updateUserAdmin,
createInvite,
deleteInvite,
createUser,
deleteUser,
resetUserPassword,
updateUserAutoApprove,
createAdminUsersQuery,
createAdminInvitesQuery,
type AdminUser,
type AdminInvite,
type CreateUserInput
} from '$lib/api/admin';
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
import Modal from '$lib/components/Modal.svelte';
import RowActionsMenu, { type RowAction } from '$lib/components/RowActionsMenu.svelte';
import { Shield, ShieldOff, CheckCircle2, XCircle, KeyRound, Trash2 } from 'lucide-svelte';
const client = useQueryClient();
const usersStore = createAdminUsersQuery();
const usersQuery = $derived($usersStore);
const users = $derived((usersQuery.data ?? []) as AdminUser[]);
const invitesStore = createAdminInvitesQuery();
const invitesQuery = $derived($invitesStore);
const invites = $derived((invitesQuery.data ?? []) as AdminInvite[]);
let saving = $state(false);
// Modal state
let showCreateModal = $state(false);
let createForm = $state<CreateUserInput & { confirmPassword: string }>({
username: '',
password: '',
confirmPassword: '',
display_name: '',
is_admin: false,
});
let resetPasswordTarget = $state<AdminUser | null>(null);
let resetPasswordValue = $state('');
let resetPasswordConfirm = $state('');
let confirmDeleteTarget = $state<AdminUser | null>(null);
async function onToggleAdmin(u: AdminUser) {
saving = true;
try {
await updateUserAdmin(u.id, !u.is_admin);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast(u.is_admin ? `${u.username} is no longer an admin.` : `${u.username} is now an admin.`);
} catch (e: unknown) {
const code = errCode(e);
if (code === 'last_admin') {
pushToast(`Can't remove the last admin — promote someone else first.`, 'error');
} else {
pushToast(`Action failed: ${code}`, 'error');
}
} finally {
saving = false;
}
}
async function onSubmitCreate(e: SubmitEvent) {
e.preventDefault();
if (createForm.password !== createForm.confirmPassword) {
pushToast('Passwords do not match.', 'error');
return;
}
saving = true;
try {
await createUser({
username: createForm.username,
password: createForm.password,
display_name: createForm.display_name || undefined,
is_admin: createForm.is_admin,
});
await client.invalidateQueries({ queryKey: qk.adminUsers() });
showCreateModal = false;
createForm = { username: '', password: '', confirmPassword: '', display_name: '', is_admin: false };
pushToast('User created.');
} catch (e: unknown) {
pushToast(createUserErrorMessage(errCode(e)), 'error');
} finally {
saving = false;
}
}
function createUserErrorMessage(code: string | undefined): string {
switch (code) {
case 'username_taken': return 'That username is already taken.';
case 'username_invalid': return 'Username must be 332 characters: letters, numbers, underscores, hyphens.';
case 'password_too_short': return 'Password must be at least 8 characters.';
default: return `Create failed: ${code ?? 'unknown'}`;
}
}
async function onConfirmDelete() {
if (!confirmDeleteTarget) return;
saving = true;
try {
await deleteUser(confirmDeleteTarget.id);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast(`Deleted ${confirmDeleteTarget.username}.`);
confirmDeleteTarget = null;
} catch (e: unknown) {
const code = errCode(e);
if (code === 'last_admin') {
pushToast(`Can't delete the last admin — promote someone else first.`, 'error');
} else {
pushToast(`Delete failed: ${code}`, 'error');
}
} finally {
saving = false;
}
}
async function onSubmitResetPassword(e: SubmitEvent) {
e.preventDefault();
if (!resetPasswordTarget) return;
if (resetPasswordValue !== resetPasswordConfirm) {
pushToast('Passwords do not match.', 'error');
return;
}
saving = true;
try {
await resetUserPassword(resetPasswordTarget.id, resetPasswordValue);
pushToast(`Password reset for ${resetPasswordTarget.username}.`);
resetPasswordTarget = null;
resetPasswordValue = '';
resetPasswordConfirm = '';
} catch (e: unknown) {
const code = errCode(e);
if (code === 'password_too_short') {
pushToast('Password must be at least 8 characters.', 'error');
} else {
pushToast(`Reset failed: ${code}`, 'error');
}
} finally {
saving = false;
}
}
async function onToggleAutoApprove(u: AdminUser) {
saving = true;
try {
await updateUserAutoApprove(u.id, !u.auto_approve_requests);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast(u.auto_approve_requests
? `Auto-approve disabled for ${u.username}.`
: `Auto-approve enabled for ${u.username}.`);
} catch (e: unknown) {
pushToast(`Toggle failed: ${errCode(e)}`, 'error');
} finally {
saving = false;
}
}
async function onGenerateInvite() {
saving = true;
try {
await createInvite();
await client.invalidateQueries({ queryKey: qk.adminInvites() });
pushToast('Invite generated. Copy the token from the list below.');
} catch (e: unknown) {
pushToast(`Generate failed: ${errCode(e)}`, 'error');
} finally {
saving = false;
}
}
async function onRevokeInvite(invite: AdminInvite) {
saving = true;
try {
await deleteInvite(invite.token);
await client.invalidateQueries({ queryKey: qk.adminInvites() });
pushToast('Invite revoked.');
} catch (e: unknown) {
pushToast(`Revoke failed: ${errCode(e)}`, 'error');
} finally {
saving = false;
}
}
async function copyToken(token: string) {
try {
await navigator.clipboard.writeText(token);
pushToast('Token copied to clipboard.');
} catch {
pushToast('Copy failed — select the token and copy manually.', 'error');
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
</script>
<svelte:head><title>{pageTitle('Admin · Users')}</title></svelte:head>
<div class="space-y-8">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Users</h2>
<p class="text-text-secondary">
Manage user accounts and invites for this Minstrel instance.
</p>
</header>
<!-- Accounts section -->
<section class="space-y-3">
<div class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Accounts</h3>
<button
type="button"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => { showCreateModal = true; }}
>
New user
</button>
</div>
{#if usersQuery.isPending}
<p class="text-text-secondary">Reading users…</p>
{:else if usersQuery.isError}
<p class="text-error">Couldn't load users.</p>
{:else if users.length === 0}
<p class="text-text-secondary">No users yet.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each users as u (u.id)}
{@const primary: RowAction = {
icon: u.is_admin ? ShieldOff : Shield,
label: u.is_admin ? 'Remove admin' : 'Make admin',
onclick: () => onToggleAdmin(u),
disabled: saving
}}
{@const secondary: RowAction[] = [
{
icon: u.auto_approve_requests ? XCircle : CheckCircle2,
label: u.auto_approve_requests ? 'Disable auto-approve' : 'Enable auto-approve',
onclick: () => onToggleAutoApprove(u),
disabled: saving
},
{
icon: KeyRound,
label: 'Reset password',
onclick: () => { resetPasswordTarget = u; },
disabled: saving
},
{
icon: Trash2,
label: 'Delete',
onclick: () => { confirmDeleteTarget = u; },
disabled: saving,
danger: true
}
]}
<li class="flex flex-wrap items-center gap-2 p-3" data-testid="user-row" data-user-id={u.id}>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="text-base font-medium text-text-primary">{u.username}</span>
{#if u.is_admin}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
data-testid="admin-badge"
>admin</span>
{/if}
{#if u.auto_approve_requests}
<span
class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
data-testid="auto-approve-badge"
>auto-approve</span>
{/if}
</div>
<div class="text-sm text-text-secondary">
{#if u.display_name}{u.display_name} · {/if}created {formatDate(u.created_at)}
</div>
</div>
<div class="shrink-0">
<RowActionsMenu {primary} {secondary} />
</div>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Invites section -->
<section class="space-y-3">
<div class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Invites</h3>
<button
type="button"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={onGenerateInvite}
>
Generate invite
</button>
</div>
<p class="text-sm text-text-secondary">
Invites expire after 24 hours and can be used once. Share the token with the
person you want to invite — they enter it on the registration page.
</p>
{#if invitesQuery.isPending}
<p class="text-text-secondary">Reading invites…</p>
{:else if invitesQuery.isError}
<p class="text-error">Couldn't load invites.</p>
{:else if invites.length === 0}
<p class="text-sm text-text-secondary">No active invites.</p>
{:else}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#each invites as inv (inv.token)}
<li class="flex items-center gap-3 p-3" data-testid="invite-row">
<code class="min-w-0 flex-1 truncate font-mono text-xs text-text-secondary">{inv.token}</code>
<span class="shrink-0 text-xs text-text-secondary">
expires {formatDate(inv.expires_at)}
</span>
<button
type="button"
aria-label={`Copy token for invite ${inv.token}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary"
onclick={() => copyToken(inv.token)}
>
Copy
</button>
<button
type="button"
disabled={saving}
aria-label={`Revoke invite ${inv.token}`}
class="inline-flex items-center gap-1 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => onRevokeInvite(inv)}
>
Revoke
</button>
</li>
{/each}
</ul>
{/if}
</section>
</div>
<Modal
title="New user"
open={showCreateModal}
onClose={() => { showCreateModal = false; }}
>
<form onsubmit={onSubmitCreate} class="space-y-3">
<div>
<label for="cu-username" class="block text-sm text-text-secondary">Username</label>
<input
id="cu-username"
type="text"
required
minlength="3"
maxlength="32"
bind:value={createForm.username}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-display" class="block text-sm text-text-secondary">
Display name <span class="text-text-muted">(optional)</span>
</label>
<input
id="cu-display"
type="text"
maxlength="64"
bind:value={createForm.display_name}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-password" class="block text-sm text-text-secondary">Password</label>
<input
id="cu-password"
type="password"
required
minlength="8"
bind:value={createForm.password}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="cu-confirm" class="block text-sm text-text-secondary">Confirm password</label>
<input
id="cu-confirm"
type="password"
required
minlength="8"
bind:value={createForm.confirmPassword}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<label class="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" bind:checked={createForm.is_admin} />
Make this user an admin
</label>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { showCreateModal = false; }}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</Modal>
<Modal
title={resetPasswordTarget ? `Reset password for ${resetPasswordTarget.username}` : ''}
open={resetPasswordTarget !== null}
onClose={() => { resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }}
>
<p class="-mt-2 mb-3 text-sm text-text-secondary">
The user will need to log in with the new password.
</p>
<form onsubmit={onSubmitResetPassword} class="space-y-3">
<div>
<label for="rp-password" class="block text-sm text-text-secondary">New password</label>
<input
id="rp-password"
type="password"
required
minlength="8"
bind:value={resetPasswordValue}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div>
<label for="rp-confirm" class="block text-sm text-text-secondary">Confirm</label>
<input
id="rp-confirm"
type="password"
required
minlength="8"
bind:value={resetPasswordConfirm}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { resetPasswordTarget = null; resetPasswordValue = ''; resetPasswordConfirm = ''; }}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Resetting…' : 'Set password'}
</button>
</div>
</form>
</Modal>
<Modal
title={confirmDeleteTarget ? `Delete ${confirmDeleteTarget.username}?` : ''}
open={confirmDeleteTarget !== null}
onClose={() => { confirmDeleteTarget = null; }}
>
<p class="-mt-2 text-sm text-text-secondary">
This permanently deletes the account and cascades through plays, likes,
sessions, and quarantine. The action cannot be undone.
</p>
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
disabled={saving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
onclick={() => { confirmDeleteTarget = null; }}
>
Cancel
</button>
<button
type="button"
disabled={saving}
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
onclick={onConfirmDelete}
>
{saving ? 'Deleting…' : 'Delete'}
</button>
</div>
</Modal>