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,