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:
2026-05-07 12:06:08 -04:00
parent 6b23532a61
commit 97d4dce7ee
6 changed files with 418 additions and 9 deletions
+56
View File
@@ -377,3 +377,59 @@ export function createScanScheduleQuery() {
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
id: string;
username: string;
display_name: string | null;
is_admin: boolean;
created_at: string;
};
export type AdminInvite = {
token: string;
invited_by: string;
note: string | null;
created_at: string;
expires_at: string;
redeemed_at: string | null;
redeemed_by: string | null;
};
export async function listUsers(): Promise<AdminUser[]> {
const res = await api.get<{ users: AdminUser[] }>('/api/admin/users');
return res.users;
}
export async function updateUserAdmin(id: string, isAdmin: boolean): Promise<AdminUser> {
return api.put<AdminUser>(`/api/admin/users/${id}/admin`, { is_admin: isAdmin });
}
export async function listInvites(): Promise<AdminInvite[]> {
const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites');
return res.invites;
}
export async function createInvite(note?: string): Promise<AdminInvite> {
return api.post<AdminInvite>('/api/admin/invites', note ? { note } : {});
}
export async function deleteInvite(token: string): Promise<void> {
await api.del(`/api/admin/invites/${token}`);
}
export function createAdminUsersQuery() {
return createQuery({
queryKey: qk.adminUsers(),
queryFn: listUsers
});
}
export function createAdminInvitesQuery() {
return createQuery({
queryKey: qk.adminInvites(),
queryFn: listInvites
});
}
+2
View File
@@ -38,6 +38,8 @@ export const qk = {
scanSchedule: () => ['scanSchedule'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
home: () => ['home'] as const,
+5 -7
View File
@@ -3,14 +3,12 @@
type Item = { href: string; label: string };
// Users and Library are deferred until they have real routes.
// Adding them as disabled items clutters the tab strip without serving the
// operator — they'll be added back when each surface ships.
const items: Item[] = [
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' }
{ href: '/admin', label: 'Overview' },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/requests', label: 'Requests' },
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/users', label: 'Users' }
];
function isActive(href: string): boolean {
+12 -2
View File
@@ -44,7 +44,16 @@ describe('AdminTabs', () => {
);
});
test('renders exactly four tabs (Users + Library deferred until built)', () => {
test('Users tab is current when on /admin/users', () => {
state.pageUrl = new URL('http://localhost/admin/users');
render(AdminTabs);
expect(screen.getByRole('link', { name: /users/i })).toHaveAttribute(
'aria-current',
'page'
);
});
test('renders exactly five tabs', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -52,7 +61,8 @@ describe('AdminTabs', () => {
'Overview',
'Integrations',
'Requests',
'Quarantine'
'Quarantine',
'Users'
]);
});
});
+213
View File
@@ -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}
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminUser, AdminInvite } from '$lib/api/admin';
// Wrap useQueryClient so the page can call invalidateQueries() without a real
// QueryClient context. Everything else from svelte-query passes through.
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
vi.mock('$lib/api/admin', () => ({
createAdminUsersQuery: vi.fn(),
createAdminInvitesQuery: vi.fn(),
listUsers: vi.fn(),
updateUserAdmin: vi.fn(),
listInvites: vi.fn(),
createInvite: vi.fn(),
deleteInvite: vi.fn()
}));
import AdminUsersPage from './+page.svelte';
import {
createAdminUsersQuery,
createAdminInvitesQuery,
updateUserAdmin,
createInvite,
deleteInvite
} from '$lib/api/admin';
const alice: AdminUser = {
id: 'u1',
username: 'alice',
display_name: null,
is_admin: true,
created_at: '2026-05-01T00:00:00Z'
};
const bob: AdminUser = {
id: 'u2',
username: 'bob',
display_name: 'Bob B',
is_admin: false,
created_at: '2026-05-02T00:00:00Z'
};
const sampleInvite: AdminInvite = {
token: 'tok-abc123',
invited_by: 'u1',
note: null,
created_at: '2026-05-06T10:00:00Z',
expires_at: '2026-05-07T10:00:00Z',
redeemed_at: null,
redeemed_by: null
};
afterEach(() => vi.clearAllMocks());
function setup(users: AdminUser[] = [alice, bob], invites: AdminInvite[] = []) {
(createAdminUsersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: users })
);
(createAdminInvitesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: invites })
);
return render(AdminUsersPage);
}
describe('/admin/users', () => {
test('renders the users list with admin badge', () => {
setup();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
expect(screen.getByTestId('admin-badge')).toHaveTextContent('admin');
});
test('empty state shows fallback copy', () => {
setup([]);
expect(screen.getByText('No users yet.')).toBeInTheDocument();
});
test('Make admin button calls updateUserAdmin(id, true)', async () => {
(updateUserAdmin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...bob, is_admin: true });
setup();
await fireEvent.click(screen.getByRole('button', { name: /make bob admin/i }));
expect(updateUserAdmin).toHaveBeenCalledWith('u2', true);
});
test('Remove admin button calls updateUserAdmin(id, false)', async () => {
(updateUserAdmin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...alice, is_admin: false });
setup();
await fireEvent.click(screen.getByRole('button', { name: /remove admin from alice/i }));
expect(updateUserAdmin).toHaveBeenCalledWith('u1', false);
});
test('last_admin error surfaces correct toast', async () => {
(updateUserAdmin as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'last_admin' });
setup();
await fireEvent.click(screen.getByRole('button', { name: /remove admin from alice/i }));
await waitFor(() =>
expect(screen.getByTestId('toast')).toHaveTextContent(/last admin/i)
);
});
test('Generate invite button calls createInvite', async () => {
(createInvite as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sampleInvite);
setup();
await fireEvent.click(screen.getByRole('button', { name: /generate invite/i }));
expect(createInvite).toHaveBeenCalled();
});
test('invite list renders token and Revoke button', () => {
setup([alice, bob], [sampleInvite]);
expect(screen.getByText('tok-abc123')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /revoke invite tok-abc123/i })).toBeInTheDocument();
});
test('Revoke button calls deleteInvite with the token', async () => {
(deleteInvite as ReturnType<typeof vi.fn>).mockResolvedValueOnce(undefined);
setup([alice, bob], [sampleInvite]);
await fireEvent.click(screen.getByRole('button', { name: /revoke invite tok-abc123/i }));
expect(deleteInvite).toHaveBeenCalledWith('tok-abc123');
});
test('empty invite state shows fallback copy', () => {
setup([alice], []);
expect(screen.getByText('No active invites.')).toBeInTheDocument();
});
});