1536860e59
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>
505 lines
18 KiB
Svelte
505 lines
18 KiB
Svelte
<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 3–32 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>
|