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