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:
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user