From 97d4dce7ee82c11a0e1c3499bcd2296deb01b56f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 12:06:08 -0400 Subject: [PATCH] feat(web/m7-user-mgmt): /admin/users page + AdminTabs entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/lib/api/admin.ts | 56 ++++++ web/src/lib/api/queries.ts | 2 + web/src/lib/components/AdminTabs.svelte | 12 +- web/src/lib/components/AdminTabs.test.ts | 14 +- web/src/routes/admin/users/+page.svelte | 213 +++++++++++++++++++++++ web/src/routes/admin/users/users.test.ts | 130 ++++++++++++++ 6 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 web/src/routes/admin/users/+page.svelte create mode 100644 web/src/routes/admin/users/users.test.ts diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 2a3d1efd..4bd2f2f9 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 { + const res = await api.get<{ users: AdminUser[] }>('/api/admin/users'); + return res.users; +} + +export async function updateUserAdmin(id: string, isAdmin: boolean): Promise { + return api.put(`/api/admin/users/${id}/admin`, { is_admin: isAdmin }); +} + +export async function listInvites(): Promise { + const res = await api.get<{ invites: AdminInvite[] }>('/api/admin/invites'); + return res.invites; +} + +export async function createInvite(note?: string): Promise { + return api.post('/api/admin/invites', note ? { note } : {}); +} + +export async function deleteInvite(token: string): Promise { + 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 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 94cf64cf..142c2bfd 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -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, diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index d5c56b19..07429f1c 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -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 { diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts index cbdb6efe..a7f152b4 100644 --- a/web/src/lib/components/AdminTabs.test.ts +++ b/web/src/lib/components/AdminTabs.test.ts @@ -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' ]); }); }); diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte new file mode 100644 index 00000000..ffd413d3 --- /dev/null +++ b/web/src/routes/admin/users/+page.svelte @@ -0,0 +1,213 @@ + + +{pageTitle('Admin · Users')} + +
+
+

Users

+

+ Manage user accounts and invites for this Minstrel instance. +

+
+ + +
+

Accounts

+ {#if usersQuery.isPending} +

Reading users…

+ {:else if usersQuery.isError} +

Couldn't load users.

+ {:else if users.length === 0} +

No users yet.

+ {:else} +
    + {#each users as u (u.id)} +
  • +
    +
    + {u.username} + {#if u.is_admin} + admin + {/if} +
    +
    + {#if u.display_name}{u.display_name} · {/if}created {formatDate(u.created_at)} +
    +
    +
    + +
    +
  • + {/each} +
+ {/if} +
+ + +
+
+

Invites

+ +
+

+ 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. +

+ {#if invitesQuery.isPending} +

Reading invites…

+ {:else if invitesQuery.isError} +

Couldn't load invites.

+ {:else if invites.length === 0} +

No active invites.

+ {:else} +
    + {#each invites as inv (inv.token)} +
  • + {inv.token} + + expires {formatDate(inv.expires_at)} + + + +
  • + {/each} +
+ {/if} +
+
+ +{#if toast} +
+ {toast} +
+{/if} diff --git a/web/src/routes/admin/users/users.test.ts b/web/src/routes/admin/users/users.test.ts new file mode 100644 index 00000000..4f9b2fd7 --- /dev/null +++ b/web/src/routes/admin/users/users.test.ts @@ -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; + 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).mockReturnValue( + mockQuery({ data: users }) + ); + (createAdminInvitesQuery as ReturnType).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).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).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).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).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).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(); + }); +});