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,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