feat(web/m7-user-mgmt): /admin/users page + AdminTabs entry
New admin route /admin/users with two sections:
- Accounts — table of all users (username, optional display name,
admin badge, created_at). Per-row "Make admin" / "Remove admin"
button calls PUT /api/admin/users/{id}/admin. Last-admin guard
surfaces as a toast ("Can't remove the last admin — promote
someone else first").
- Invites — list of active invites with Copy + Revoke per row.
"Generate invite" button creates a 24h token. Operator shares
the token with the invitee, who enters it on /register.
AdminTabs gains a "Users" tab (5 tabs total). New typed client
functions in admin.ts (listUsers, updateUserAdmin, listInvites,
createInvite, deleteInvite) plus query factory functions
createAdminUsersQuery / createAdminInvitesQuery and query keys
qk.adminUsers / qk.adminInvites.
Tests cover: list rendering, admin badge, promote/demote actions,
last-admin guard toast, invite generation, revoke flow, empty states.
AdminTabs.test.ts updated to assert 5 tabs and Users active state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import {
|
||||
updateUserAdmin,
|
||||
createInvite,
|
||||
deleteInvite,
|
||||
createAdminUsersQuery,
|
||||
createAdminInvitesQuery,
|
||||
type AdminUser,
|
||||
type AdminInvite
|
||||
} from '$lib/api/admin';
|
||||
|
||||
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 toast = $state<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let saving = $state(false);
|
||||
|
||||
function showToast(msg: string) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast = msg;
|
||||
toastTimer = setTimeout(() => {
|
||||
toast = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function onToggleAdmin(u: AdminUser) {
|
||||
saving = true;
|
||||
try {
|
||||
await updateUserAdmin(u.id, !u.is_admin);
|
||||
await client.invalidateQueries({ queryKey: qk.adminUsers() });
|
||||
showToast(u.is_admin ? `${u.username} is no longer an admin.` : `${u.username} is now an admin.`);
|
||||
} catch (e: unknown) {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code === 'last_admin') {
|
||||
showToast(`Can't remove the last admin — promote someone else first.`);
|
||||
} else {
|
||||
showToast(`Action failed: ${code ?? 'unknown'}`);
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerateInvite() {
|
||||
saving = true;
|
||||
try {
|
||||
await createInvite();
|
||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
showToast('Invite generated. Copy the token from the list below.');
|
||||
} catch (e: unknown) {
|
||||
showToast(`Generate failed: ${(e as { code?: string })?.code ?? 'unknown'}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeInvite(invite: AdminInvite) {
|
||||
saving = true;
|
||||
try {
|
||||
await deleteInvite(invite.token);
|
||||
await client.invalidateQueries({ queryKey: qk.adminInvites() });
|
||||
showToast('Invite revoked.');
|
||||
} catch (e: unknown) {
|
||||
showToast(`Revoke failed: ${(e as { code?: string })?.code ?? 'unknown'}`);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
showToast('Token copied to clipboard.');
|
||||
} catch {
|
||||
showToast('Copy failed — select the token and copy manually.');
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="font-display text-xl font-medium text-text-primary">Accounts</h3>
|
||||
{#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)}
|
||||
<li class="flex items-center gap-4 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}
|
||||
</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="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
aria-label={u.is_admin ? `Remove admin from ${u.username}` : `Make ${u.username} admin`}
|
||||
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={() => onToggleAdmin(u)}
|
||||
>
|
||||
{u.is_admin ? 'Remove admin' : 'Make admin'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{#if toast}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-md border border-border bg-surface px-4 py-3 text-sm text-text-primary shadow-lg"
|
||||
data-testid="toast"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user