Prima Get an API key

Module kit / Identity and auth

Admin users console

identity-auth/ui/admin-users-consolelive

Kind frontend
Surface SPA
Maturity prod
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders identity-auth/admin-user-management-and-impersonation, identity-auth/multi-tenant-workspaces, identity-auth/surface-registry-and-gate, identity-auth/rate-limiting
Entry alchemy_labs/frontend/src/pages/AdminUsers.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/AdminUsers.tsx
  • alchemy_labs/frontend/src/components/AdminRoute.tsx
  • alchemy_labs/frontend/src/components/AdminNavigation.tsx
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/contexts/AuthContext.tsx
  • alchemy_labs/backend/api/routers/admin_router.py
  • alchemy_labs/backend/services/admin_service.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Version 1

Purpose

Populated: seven accounts, two admins, one inactive, the stat cards computed from the page default server rendered, hydrated in view

User Management

View and manage platform users

Total Users

7

Active Users

6

Admins

2

User
Status
Client Status
Subscription
Tokens
Actions
AdminActive
enterprise

1,000,000

quota

412,000 used

Active
free

100,000

quota

63,500 used

+25,000 voucher

Active
pro

500,000

quota

498,000 used

Inactive
free

100,000

quota

0 used

AdminActive
enterprise

1,000,000

quota

120,000 used

Active
free

100,000

quota

8,200 used

DE

[email protected]

@desk-shared

Active
free

100,000

quota

0 used

A platform admin opens /admin/users and sees every account as a table they can search and filter, with three counts above it, and from each row credits tokens, appoints or revokes admin and changes the client status; a dialog creates an account with its Labs grants and personal workspace. The invariant that makes it trustworthy is that the page holds no authority of its own: AdminRoute hides the route from a non-admin, and every row action and the dialog go to a B16 route behind AdminUser, which re-reads users.is_admin on every call, so a person who reaches the page by editing the bundle can still do nothing the API refuses.

It makes visible identity-auth/admin-user-management-and-impersonation (B16: the list, the five writes the page calls, their sentences), the admin-created account's grants from identity-auth/surface-registry-and-gate (B4: LABS_SIGNUP_SURFACES written by create-user) and personal workspace from identity-auth/multi-tenant-workspaces (B15), and identity-auth/rate-limiting (B18) only as the 429 the page cannot yet render (Gotchas).

When to use

Trigger phrases: "make someone admin", "the users page", "create a client account", "add tokens to", "who is inactive", "the admin table", "search users by email", "why does the console say Failed to load users". Surface: SPA, because it is the platform's own operator screen, sits inside the shell's agency group (nav.tsx domain admin, label "Operate", adminOnly: true, lines 307 to 336 at fd8558f) and is the one place that reads is_admin for visibility.

Not this recipe: the audit log page (/admin/audit, a separate route the inventory files under monitoring), the platform-wide workspace list (GET /api/admin/workspaces, B15's frontend is identity-auth/ui/workspace-switcher-and-bootstrap), the per-user API key an admin mints (POST /api/admin/users/{id}/api-key, B10, rendered by identity-auth/ui/api-keys-settings-and-developer-dashboard), the agency self-grant (no page calls it at fd8558f, B16 Gotchas), and the login page an admin arrives through (identity-auth/ui/auth-screens).

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per S2 at fd8558f: react 19.2.0, react-router-dom 7.x, tailwindcss 3.4.18, lucide-react 0.563.0, axios 1.x. Component library: @prima-modules/alchemy-ui 0.1.0 (the atoms and molecules at fd8558f), of which the page uses Heading, Text, Button, Input, Select, Card, Badge, Spinner, DataTable with Column, Avatar and useToast (AdminUsers.tsx lines 4 to 18). Data-fetch style: the api singleton from services/api.ts for every call (the Bearer header and the single-flight 401 refresh of S2; X-Workspace-ID is added when currentWorkspaceId is in localStorage and ignored by these routes, which are platform-wide). Copy language: English, the console's chrome and every sentence on the page (en in the frontmatter, as the shell's chrome is; the de-CH of the client surfaces does not apply to an operator screen). Two native browser dialogs stand in for atoms: window.prompt for the token amount and the reason (lines 97 and 100), window.confirm before appoint or revoke (line 116).

Data model

Client state (AdminUsers.tsx lines 49 to 68), all in the page component, none persisted:

interface User {                       // one row of GET /api/admin/users-list (admin_service.py 357 to 372)
  id: string; email: string; username: string;
  is_admin: boolean; is_active: boolean;
  subscription_tier: string; client_status: string;      // 'new' | 'in_project' | 'full'
  token_quota: number; tokens_used_current_period: number; voucher_tokens: number;
  created_at: string; last_login: string | null;
  [key: string]: unknown;                                  // DataTable's T extends Record<string, unknown>
}
users: User[]; loading: boolean (initially true); error: string | null;
searchQuery: string; filterAdmin: 'all' | 'admin' | 'user';
showCreateModal: boolean; creating: boolean;
newUser: { email, username, password, first_name, last_name, company, client_status: 'new' }   // the dialog's draft

Derived on render: totalUsers, activeUsers, adminUsers are counts over users (lines 314 to 316), so the three cards describe the rows the last fetch returned (at most limit: 100, line 77), not the table. The admin_appointed_at the route returns is not in the interface and not rendered.

The demo's props (demo.tsx): { viewer: ShellUser, workspace: ShellWorkspace, users: AdminUserRow[], status: 'ok' | 'loading' | 'error', error?, search, filter, createOpen, creating?, draft? }, with the row type named AdminUserRow and identical to User above.

Browser storage: nothing of its own. What the page relies on is S2's: accessToken and refreshToken in localStorage (or memory under VITE_USE_COOKIE_AUTH, config/authConfig.ts lines 87 to 120), currentWorkspaceId (read by the interceptor, api.ts line 38), and alchemy.onboarding.* for the tour (the verify block switches it off). Nothing about the rows, the search or the draft survives a reload.

API surface

Contracts consumed, all on /api/admin (B16), each with AdminUser, so 401 without a credential and 403 "Admin access required" for a signed-in non-admin (S5), and 403 "This session belongs to the '<surface>' surface and cannot be used here." for a Prima-surface session at the gate (B4):

Call (AdminUsers.tsx line)RouteParsedRendered as
api.get('/api/admin/users-list', { params }) (81)GET /users-list?skip=0&limit=100&search=&is_admin=response.data.users (the list; admin_service.get_all_users returns [] on its own exception, 376 to 378, so a database fault reads as an empty table, never as error)the table; the counts; "No users found" when empty
api.post('/api/admin/add-tokens', {user_id, token_amount, reason?}) (103)POST /add-tokensnothing on 200 ({message, new_balance} discarded); on failure err.response?.data?.detailtoast "Tokens added successfully!"; toast error "Failed to add tokens" (the 400 "Token amount must be positive" never reaches the toast, Gotchas)
api.post('/api/admin/appoint-admin' or '/revoke-admin', {user_id}) (121 to 122)POST /appoint-admin, POST /revoke-adminnothing on 200toast "Admin privileges granted successfully!" or "... revoked successfully!"; toast error "Failed to update admin status" (the 400s "User is already an admin", "Super admin cannot revoke their own privileges", "User is not an admin" are not shown, Gotchas)
api.patch('/api/admin/users/{id}/client-status', {status}) (132)PATCH /users/{id}/client-statusnothing; refetchno success toast; toast error "Failed to update client status"
api.post('/api/admin/create-user', {...}) (147)POST /create-usernothing on 200 ({id, email, username, client_status, is_active} discarded); refetchdialog closes, draft cleared; toast error "Failed to create user" (the 400 "User with email <e> already exists" is not shown, Gotchas)

Not called by the page although the router offers them (B16 API surface): PATCH /users/{id}/status (the off switch: the console renders "Inactive" but cannot set it), PATCH /users/{id}/role, POST /users/{id}/impersonate (no caller anywhere in frontend/src), GET /audit-log, GET /active-users, GET /user-activity/{id}, POST /agency-access, DELETE /agency-access/{id}. The inventory's tree for F4 lists "impersonate; agency access; grants per surface as a column; audit row after every action"; at fd8558f none of the four is on the page, and the frontmatter tree above is the measured one.

Error bodies as this page reads them: every refusal is Shape B, {error: <sentence>, code, request_id, timestamp} (S7; app_fastapi.py 774 to 788 renders a string detail as error, and create_error_response writes no detail key, schemas/error_schemas.py 130 to 136). The page reads err.response?.data?.detail in all five catch blocks (85, 111, 126, 137, 160), which is undefined on that shape, so every refusal renders as the fallback sentence beside it. A 429 from B18 ({error: "rate_limit_exceeded", message} from the middleware, or the envelope's RATE_001) is a fallback sentence too; Retry-After is not read. No WebSocket, no postMessage.

Application logic

  1. /admin/users mounts AdminRoute (AdminRoute.tsx 13 to 40): while useAuth().loading, a bare spinner; no user, <Navigate to="/login" replace />; !user.isAdmin, <Navigate to="/" replace />; else the page. isAdmin is /api/auth/me's is_admin (AuthContext.tsx 104, 139, 203).
  2. AdminUsers mounts with loading: true and no rows, so it returns the spinner screen before any chrome (318 to 327); useEffect fires fetchUsers() (70 to 72).
  3. fetchUsers builds {skip: 0, limit: 100}, adds search when the box is non-empty and is_admin when the filter is not all, calls GET /users-list, stores response.data.users, clears error; on a throw stores the fallback sentence; loading false either way (74 to 89). The page renders inside AdminNavigation (a delegator to AppShell since C3, AdminNavigation.tsx 5 to 22), with the header, the three counts, the filter card, the error card when set, and the table.
  4. Typing in the search box changes state only; Enter or "Search" submits the form and refetches (91 to 94). Changing the Select refetches at once through the effect's dependency (72).
  5. "Add Tokens" on a row: prompt for the amount (default '100000'), cancel aborts; prompt for the reason (optional); POST /add-tokens with parseInt; success toast and refetch, or the error toast (96 to 113).
  6. "Make Admin" or "Revoke Admin": confirm("<Grant|Revoke> admin privileges for <email>?"), cancel aborts; POST to the matching route; toast and refetch (115 to 128).
  7. The client-status select on a row: PATCH on change, refetch on success, error toast on failure; no confirmation (130 to 139). stopPropagation on click and change keeps the row's onRowClick (unset here) out of it.
  8. "Create User" opens the dialog; the submit button is disabled until email is non-empty; submit posts the draft with empty strings turned into undefined (149 to 154) and client_status always sent; on 200 the dialog closes and the draft resets; the busy flag drives the spinner (141 to 164). Cancel or the X closes without a reset of the draft.
  9. Sign-out and workspace switching are the shell's (ui-design-system/ui/appshell-navigation); the page owns no navigation.

Invariants

  • No mutation without an endpoint: the five writes are the five B16 routes named above, and nothing on the page changes a row locally; every success path is a refetch (109, 124, 135, 158), so what the table shows is what the server answered.
  • Visibility is not authorisation: AdminRoute and the rail's adminOnly filter read isAdmin from /me; the routes re-check users.is_admin per call (B16 Invariants), and a non-admin who reaches the bundle's route gets 403 on the first fetch.
  • The counts are over the fetched rows, never a separate total; with limit: 100 and more than 100 accounts the cards understate, which the rebuild fixes by rendering the route's own totals or by naming the page size in the card (Gotchas).
  • A privileged action asks first: appoint, revoke and add-tokens each pass through a confirmation before the request leaves; the client-status change does not (Gotchas).
  • The dialog's password is optional and travels once: an empty field means a passwordless account (placeholder line 538, password || undefined line 150); the value is never stored client-side beyond the draft state and is cleared with it on success.
  • The raw request errors are not rendered: only the page's own fallback sentences and toasts reach the screen (which is the wrong kind of safety, Gotchas).

Frontend

The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.

First fetch: the spinner the page returns before the shell (line 318) loading server rendered, hydrated in view

Loading users...

Search with no hit: "No users found" no-match server rendered, hydrated in view

User Management

View and manage platform users

Total Users

0

Active Users

0

Admins

0

User
Status
Client Status
Subscription
Tokens
Actions
No users found
Filter "Admins Only": the two admin rows, the "Revoke Admin" action on each admins-only server rendered, hydrated in view

User Management

View and manage platform users

Total Users

2

Active Users

2

Admins

2

User
Status
Client Status
Subscription
Tokens
Actions
AdminActive
enterprise

1,000,000

quota

412,000 used

AdminActive
enterprise

1,000,000

quota

120,000 used

Create User dialog open, filled: the password field stays empty for a passwordless account create-open server rendered, hydrated in view

User Management

View and manage platform users

Total Users

7

Active Users

6

Admins

2

User
Status
Client Status
Subscription
Tokens
Actions
AdminActive
enterprise

1,000,000

quota

412,000 used

Active
free

100,000

quota

63,500 used

+25,000 voucher

Active
pro

500,000

quota

498,000 used

Inactive
free

100,000

quota

0 used

AdminActive
enterprise

1,000,000

quota

120,000 used

Active
free

100,000

quota

8,200 used

DE

[email protected]

@desk-shared

Active
free

100,000

quota

0 used

Create New User

Create User submitted: the button carries the spinner and "Creating..." create-busy server rendered, hydrated in view

User Management

View and manage platform users

Total Users

7

Active Users

6

Admins

2

User
Status
Client Status
Subscription
Tokens
Actions
AdminActive
enterprise

1,000,000

quota

412,000 used

Active
free

100,000

quota

63,500 used

+25,000 voucher

Active
pro

500,000

quota

498,000 used

Inactive
free

100,000

quota

0 used

AdminActive
enterprise

1,000,000

quota

120,000 used

Active
free

100,000

quota

8,200 used

DE

[email protected]

@desk-shared

Active
free

100,000

quota

0 used

Create New User

identity-auth/ui/admin-users-console/demo.tsx
/**
 * Live demo for identity-auth/ui/admin-users-console.
 *
 * Labs' AdminUsers.tsx (fd8558f, 591 lines) with its one read and five
 * writes replaced by props: the rows GET /api/admin/users-list answered, the
 * request status, the search box and the admin filter, and whether the
 * create-user dialog is open and busy. Rendered inside the shell demo's
 * component (AdminNavigation.tsx is a delegator to AppShell since C3, so
 * the console's chrome is the same three-column shell every private page
 * has), with the "Operate" domain active and "Users" highlighted in the
 * column.
 *
 * What is kept from the source: the header, the three stat cards computed
 * from the rows on the page (lines 314 to 316: totals of what was fetched,
 * not of the table), the search form and the Select filter, the red error
 * card, the DataTable with its six columns (user, status badges, the
 * client-status select, the subscription badge, the tokens column with the
 * quota bar and the voucher line, the two ghost actions) and the create
 * dialog with its seven fields. The copy is the source's, English.
 *
 * What is different, and why: the dialog is absolute to the frame instead
 * of fixed to the viewport; the two row actions and the dialog's submit do
 * nothing (Labs calls window.prompt and window.confirm before POSTing, and
 * a demo reaches no network); the loading state renders without the shell
 * because the source returns the spinner before <AdminNavigation> (line
 * 318); numbers are grouped by hand with a comma (the source calls
 * toLocaleString(), whose output differs between Node's ICU and the
 * browser and would not survive hydration); and `shrink-0` is added on the
 * action buttons so a narrower stage does not wrap their labels.
 */
import React, { useState } from 'react';
import { AlertCircle, Coins, Plus, Search, Shield, UserCheck, UserPlus, Users, X } from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Avatar, Badge, Button, Card, DataTable, Heading, Input, Select, Spinner, Text, type Column } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- props ----------------------------------------------------------- */

/** One row of GET /api/admin/users-list (admin_service.py get_all_users, lines 357 to 372). */
export interface AdminUserRow {
  id: string;
  email: string;
  username: string;
  is_admin: boolean;
  is_active: boolean;
  subscription_tier: string;
  client_status: 'new' | 'in_project' | 'full';
  token_quota: number;
  tokens_used_current_period: number;
  voucher_tokens: number;
  created_at: string;
  last_login: string | null;
  [key: string]: unknown;
}

export interface Props {
  /** The signed-in admin, for the shell's account row and the agency group. */
  viewer: ShellUser;
  workspace: ShellWorkspace;
  users: AdminUserRow[];
  /** `loading` is the first fetch (spinner, no shell); `error` is the red card over an empty table. */
  status: 'ok' | 'loading' | 'error';
  error?: string;
  search: string;
  filter: 'all' | 'admin' | 'user';
  createOpen: boolean;
  /** The dialog's submit in flight: spinner and "Creating...". */
  creating?: boolean;
  /** What the dialog holds; complete so the filled state is reproducible. */
  draft?: Draft;
}

export interface Draft {
  email: string;
  username: string;
  password: string;
  first_name: string;
  last_name: string;
  company: string;
  client_status: 'new' | 'in_project' | 'full';
}

/* ---- sample data ------------------------------------------------------ */

const viewer: ShellUser = { name: 'Jonas Weber', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Agency workspace', slug: 'agency' };

const rows: AdminUserRow[] = [
  { id: 'u_01', email: '[email protected]', username: 'jonas', is_admin: true, is_active: true, subscription_tier: 'enterprise', client_status: 'full', token_quota: 1000000, tokens_used_current_period: 412000, voucher_tokens: 0, created_at: '2026-01-12T09:00:00Z', last_login: '2026-09-12T07:10:00Z' },
  { id: 'u_02', email: '[email protected]', username: 'mira', is_admin: false, is_active: true, subscription_tier: 'free', client_status: 'in_project', token_quota: 100000, tokens_used_current_period: 63500, voucher_tokens: 25000, created_at: '2026-03-02T10:00:00Z', last_login: '2026-09-11T16:42:00Z' },
  { id: 'u_03', email: '[email protected]', username: 'lea', is_admin: false, is_active: true, subscription_tier: 'pro', client_status: 'full', token_quota: 500000, tokens_used_current_period: 498000, voucher_tokens: 0, created_at: '2026-04-18T08:30:00Z', last_login: '2026-09-10T12:05:00Z' },
  { id: 'u_04', email: '[email protected]', username: 'noah', is_admin: false, is_active: false, subscription_tier: 'free', client_status: 'new', token_quota: 100000, tokens_used_current_period: 0, voucher_tokens: 0, created_at: '2026-05-05T14:00:00Z', last_login: null },
  { id: 'u_05', email: '[email protected]', username: 'sara', is_admin: true, is_active: true, subscription_tier: 'enterprise', client_status: 'full', token_quota: 1000000, tokens_used_current_period: 120000, voucher_tokens: 0, created_at: '2026-02-20T09:15:00Z', last_login: '2026-09-12T06:55:00Z' },
  { id: 'u_06', email: '[email protected]', username: 'tim', is_admin: false, is_active: true, subscription_tier: 'free', client_status: 'new', token_quota: 100000, tokens_used_current_period: 8200, voucher_tokens: 0, created_at: '2026-08-30T11:00:00Z', last_login: '2026-09-01T09:00:00Z' },
  { id: 'u_07', email: '[email protected]', username: 'desk-shared', is_admin: false, is_active: true, subscription_tier: 'free', client_status: 'in_project', token_quota: 100000, tokens_used_current_period: 0, voucher_tokens: 0, created_at: '2026-09-03T08:00:00Z', last_login: '2026-09-11T08:20:00Z' },
];

const emptyDraft: Draft = { email: '', username: '', password: '', first_name: '', last_name: '', company: '', client_status: 'new' };
const filledDraft: Draft = { email: '[email protected]', username: '', password: '', first_name: 'Anna', last_name: 'Roth', company: 'Praxis am See', client_status: 'in_project' };

const base: Props = {
  viewer,
  workspace,
  users: rows,
  status: 'ok',
  search: '',
  filter: 'all',
  createOpen: false,
  draft: emptyDraft,
};

export const states = defineStates<Props>({
  default: { label: 'Populated: seven accounts, two admins, one inactive, the stat cards computed from the page', props: base, height: 900, viewport: 1440 },
  loading: { label: 'First fetch: the spinner the page returns before the shell (line 318)', props: { ...base, users: [], status: 'loading' }, height: 360 },
  error: { label: 'Failed read: the red card with the detail string, an empty table below', props: { ...base, users: [], status: 'error', error: 'Failed to load users' }, height: 760, viewport: 1440 },
  'no-match': { label: 'Search with no hit: "No users found"', props: { ...base, users: [], search: 'nobody' }, height: 760, viewport: 1440 },
  'admins-only': { label: 'Filter "Admins Only": the two admin rows, the "Revoke Admin" action on each', props: { ...base, users: rows.filter((u) => u.is_admin), filter: 'admin' }, height: 760, viewport: 1440 },
  'create-open': { label: 'Create User dialog open, filled: the password field stays empty for a passwordless account', props: { ...base, createOpen: true, draft: filledDraft }, height: 900, viewport: 1440 },
  'create-busy': { label: 'Create User submitted: the button carries the spinner and "Creating..."', props: { ...base, createOpen: true, creating: true, draft: filledDraft }, height: 900, viewport: 1440 },
});

/* ---- helpers (AdminUsers.tsx lines 37 to 47) ------------------------- */

const CLIENT_STATUS_OPTIONS = [
  { value: 'new', label: 'New' },
  { value: 'in_project', label: 'In Project' },
  { value: 'full', label: 'Full Access' },
];

/** Grouped by hand: toLocaleString() is locale-dependent and breaks hydration. */
const fmt = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

/* ---- the page ---------------------------------------------------------- */

function Console({ users, status, error, search: searchInitial, filter: filterInitial, createOpen: createInitial, creating = false, draft: draftInitial = emptyDraft }: Omit<Props, 'viewer' | 'workspace'>) {
  const [searchQuery, setSearchQuery] = useState(searchInitial);
  const [filterAdmin, setFilterAdmin] = useState<'all' | 'admin' | 'user'>(filterInitial);
  const [showCreateModal, setShowCreateModal] = useState(createInitial);
  const [newUser, setNewUser] = useState<Draft>(draftInitial);

  const columns: Column<AdminUserRow>[] = [
    {
      key: 'email',
      header: 'User',
      sortable: true,
      render: (user) => (
        <div className="flex items-center gap-3">
          <Avatar name={user.username} size="sm" />
          <div>
            <Text size="sm" weight="medium">{user.email}</Text>
            <Text size="xs" muted>@{user.username}</Text>
          </div>
        </div>
      ),
    },
    {
      key: 'is_active',
      header: 'Status',
      sortable: true,
      render: (user) => (
        <div className="flex items-center gap-2 flex-wrap">
          {user.is_admin && (
            <Badge variant="primary" size="sm" dot>Admin</Badge>
          )}
          {user.is_active ? (
            <Badge variant="success" size="sm" dot>Active</Badge>
          ) : (
            <Badge variant="error" size="sm" dot>Inactive</Badge>
          )}
        </div>
      ),
    },
    {
      key: 'client_status',
      header: 'Client Status',
      sortable: true,
      render: (user) => (
        /* A native select in the source (lines 209 to 221), PATCHing on change; here it changes nothing. */
        <select
          defaultValue={user.client_status || 'new'}
          onClick={(e) => e.stopPropagation()}
          className="text-xs font-medium px-2 py-1 rounded-md border border-stone-300 bg-white text-stone-800 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
        >
          {CLIENT_STATUS_OPTIONS.map((opt) => (
            <option key={opt.value} value={opt.value}>{opt.label}</option>
          ))}
        </select>
      ),
    },
    {
      key: 'subscription_tier',
      header: 'Subscription',
      sortable: true,
      render: (user) => (
        <Badge variant="outline" size="sm" className="capitalize">
          {user.subscription_tier}
        </Badge>
      ),
    },
    {
      key: 'token_quota',
      header: 'Tokens',
      sortable: true,
      render: (user) => (
        <div className="space-y-1">
          <div className="flex items-center gap-2">
            <Text size="sm" weight="medium">{fmt(user.token_quota)}</Text>
            <Text size="xs" muted>quota</Text>
          </div>
          <div className="flex items-center gap-2">
            <div className="w-24 h-1.5 bg-stone-200 rounded-full overflow-hidden">
              <div
                className="h-full bg-primary-500 rounded-full transition-all"
                style={{ width: `${Math.min((user.tokens_used_current_period / user.token_quota) * 100, 100)}%` }}
              />
            </div>
            <Text size="xs" muted>{fmt(user.tokens_used_current_period)} used</Text>
          </div>
          {user.voucher_tokens > 0 && (
            <div className="flex items-center gap-1">
              <Coins className="w-3 h-3 text-green-600" />
              <Text size="xs" className="text-green-600">+{fmt(user.voucher_tokens)} voucher</Text>
            </div>
          )}
        </div>
      ),
    },
    {
      key: 'id',
      header: 'Actions',
      render: (user) => (
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="sm" title="Add tokens" className="shrink-0" onClick={(e) => e.stopPropagation()}>
            <Coins className="w-4 h-4" />
            Add Tokens
          </Button>
          <Button
            variant="ghost"
            size="sm"
            title={user.is_admin ? 'Revoke admin' : 'Grant admin'}
            className={`shrink-0 ${user.is_admin ? 'text-red-600 hover:text-red-700' : ''}`}
            onClick={(e) => e.stopPropagation()}
          >
            {user.is_admin ? (
              <>
                <Shield className="w-4 h-4" />
                Revoke Admin
              </>
            ) : (
              <>
                <Plus className="w-4 h-4" />
                Make Admin
              </>
            )}
          </Button>
        </div>
      ),
    },
  ];

  const totalUsers = users.length;
  const activeUsers = users.filter((u) => u.is_active).length;
  const adminUsers = users.filter((u) => u.is_admin).length;

  return (
    <>
      <div className="max-w-7xl mx-auto px-6 py-6">
        <div className="flex items-center justify-between mb-8">
          <div>
            <Heading level={1}>User Management</Heading>
            <Text muted className="mt-2">View and manage platform users</Text>
          </div>
          <Button variant="primary" size="md" className="shrink-0" onClick={() => setShowCreateModal(true)}>
            <UserPlus className="w-4 h-4" />
            Create User
          </Button>
        </div>

        <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
          <Card variant="glass" padding="md">
            <div className="flex items-center gap-4">
              <div className="p-3 rounded-2xl bg-primary-100">
                <Users className="h-6 w-6 text-primary-600" />
              </div>
              <div>
                <Text size="sm" muted>Total Users</Text>
                <Heading level={3}>{totalUsers}</Heading>
              </div>
            </div>
          </Card>
          <Card variant="glass" padding="md">
            <div className="flex items-center gap-4">
              <div className="p-3 rounded-2xl bg-green-100">
                <UserCheck className="h-6 w-6 text-green-600" />
              </div>
              <div>
                <Text size="sm" muted>Active Users</Text>
                <Heading level={3} className="text-green-600">{activeUsers}</Heading>
              </div>
            </div>
          </Card>
          <Card variant="glass" padding="md">
            <div className="flex items-center gap-4">
              <div className="p-3 rounded-2xl bg-blue-100">
                <Shield className="h-6 w-6 text-blue-600" />
              </div>
              <div>
                <Text size="sm" muted>Admins</Text>
                <Heading level={3} className="text-blue-600">{adminUsers}</Heading>
              </div>
            </div>
          </Card>
        </div>

        <Card variant="glass" padding="md" className="mb-8">
          <div className="flex flex-col md:flex-row gap-4">
            <form onSubmit={(e) => e.preventDefault()} className="flex-1">
              <div className="flex gap-2">
                <div className="flex-1 relative">
                  <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
                    <Search className="h-5 w-5 text-neutral-400" />
                  </div>
                  <Input
                    type="text"
                    value={searchQuery}
                    onChange={(e) => setSearchQuery(e.target.value)}
                    placeholder="Search by email or username..."
                    inputSize="sm"
                    className="pl-10"
                  />
                </div>
                <Button type="submit" variant="primary" size="sm" className="shrink-0">
                  Search
                </Button>
              </div>
            </form>
            <div className="w-48">
              <Select
                value={filterAdmin}
                onChange={(value) => setFilterAdmin(value as 'all' | 'admin' | 'user')}
                options={[
                  { value: 'all', label: 'All Users' },
                  { value: 'admin', label: 'Admins Only' },
                  { value: 'user', label: 'Regular Users' },
                ]}
                size="sm"
              />
            </div>
          </div>
        </Card>

        {status === 'error' && (
          <Card variant="glass" padding="md" className="mb-6 border-red-200">
            <div className="flex items-center gap-3">
              <div className="p-2 rounded-full bg-red-100">
                <AlertCircle className="h-5 w-5 text-red-600" />
              </div>
              <Text className="text-red-800">{error || 'Failed to load users'}</Text>
            </div>
          </Card>
        )}

        <Card variant="glass" padding="none" className="overflow-hidden" data-gallery-focus="true">
          <DataTable data={users} columns={columns} size="md" hoverable emptyMessage="No users found" getRowKey={(user) => user.id} />
        </Card>
      </div>

      {showCreateModal && (
        /* fixed inset-0 in the source (line 455); absolute here so the dialog covers the frame, not the docs page. */
        <div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
          <div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 border border-line">
            <div className="flex items-center justify-between px-6 py-4 border-b border-line">
              <Heading level={3}>Create New User</Heading>
              <button type="button" onClick={() => setShowCreateModal(false)} className="p-1 rounded-lg hover:bg-stone-100 text-stone-500" aria-label="Close">
                <X className="w-5 h-5" />
              </button>
            </div>

            <form onSubmit={(e) => e.preventDefault()} className="p-6 space-y-4">
              <div>
                <label className="block text-sm font-medium text-stone-700 mb-1">Email *</label>
                <Input type="email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} placeholder="[email protected]" required />
              </div>
              <div className="grid grid-cols-2 gap-4">
                <div>
                  <label className="block text-sm font-medium text-stone-700 mb-1">First Name</label>
                  <Input type="text" value={newUser.first_name} onChange={(e) => setNewUser({ ...newUser, first_name: e.target.value })} placeholder="Max" />
                </div>
                <div>
                  <label className="block text-sm font-medium text-stone-700 mb-1">Last Name</label>
                  <Input type="text" value={newUser.last_name} onChange={(e) => setNewUser({ ...newUser, last_name: e.target.value })} placeholder="Muster" />
                </div>
              </div>
              <div>
                <label className="block text-sm font-medium text-stone-700 mb-1">Company</label>
                <Input type="text" value={newUser.company} onChange={(e) => setNewUser({ ...newUser, company: e.target.value })} placeholder="Company name" />
              </div>
              <div>
                <label className="block text-sm font-medium text-stone-700 mb-1">Username</label>
                <Input type="text" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} placeholder="Auto-generated from email if empty" />
              </div>
              <div>
                <label className="block text-sm font-medium text-stone-700 mb-1">Password</label>
                <Input type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} placeholder="Leave empty for passwordless account" autoComplete="new-password" />
              </div>
              <div>
                <label className="block text-sm font-medium text-stone-700 mb-1">Client Status</label>
                <select
                  value={newUser.client_status}
                  onChange={(e) => setNewUser({ ...newUser, client_status: e.target.value as Draft['client_status'] })}
                  className="w-full px-3 py-2 rounded-lg border border-stone-300 bg-white text-stone-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
                >
                  {CLIENT_STATUS_OPTIONS.map((opt) => (
                    <option key={opt.value} value={opt.value}>{opt.label}</option>
                  ))}
                </select>
              </div>
              <div className="flex justify-end gap-3 pt-2">
                <Button type="button" variant="ghost" onClick={() => setShowCreateModal(false)}>
                  Cancel
                </Button>
                <Button type="submit" variant="primary" disabled={creating || !newUser.email}>
                  {creating ? (
                    <>
                      <Spinner size="sm" />
                      Creating...
                    </>
                  ) : (
                    <>
                      <UserPlus className="w-4 h-4" />
                      Create User
                    </>
                  )}
                </Button>
              </div>
            </form>
          </div>
        </div>
      )}
    </>
  );
}

/** The early return of AdminUsers.tsx lines 318 to 327: no shell, a centred spinner. */
function LoadingScreen() {
  return (
    <div className="flex items-center justify-center min-h-[320px] bg-paper text-ink font-sans">
      <div className="text-center">
        <Spinner size="xl" />
        <Text muted className="mt-4">Loading users...</Text>
      </div>
    </div>
  );
}

export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  if (page.status === 'loading' && page.users.length === 0) return <LoadingScreen />;
  return (
    <Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/admin/users" hasTour={false} hideChrome={galleryMode}>
      <Console {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /admin/users> AdminRoute (loading spinner | Navigate /login | Navigate / | children)
  AdminUsers
    [loading && no rows]  div.min-h-screen > Spinner xl + Text "Loading users..."     (returned BEFORE the shell, line 318)
    AdminNavigation = AppShell                                                          (Operate active in the rail, "Users" in the column)
      div.max-w-7xl
        header   Heading 1 "User Management", Text muted; Button primary "Create User"
        stats    3 x Card glass: Users / UserCheck / Shield icon, Text muted label, Heading 3 count (computed from the rows on the page)
        filters  Card glass: form(Search icon + Input sm "Search by email or username..." + Button sm "Search"); Select sm (All Users | Admins Only | Regular Users)
        [error]  Card glass border-red-200: AlertCircle + Text red (the fallback sentence)
        table    Card glass padding none > DataTable md hoverable, emptyMessage "No users found"
                   columns: User (Avatar sm + email + @username) | Status (Badge Admin primary, Active success | Inactive error, dot)
                            | Client Status (native select: New | In Project | Full Access, PATCH on change)
                            | Subscription (Badge outline) | Tokens (quota, bar, used, voucher line) | Actions (ghost "Add Tokens", ghost "Make Admin" | "Revoke Admin" red)
      [createOpen] div.fixed.inset-0 bg-black/50 > dialog card: Heading 3 "Create New User", X
                     form: Email* | First Name, Last Name | Company | Username | Password (empty = passwordless) | Client Status select
                           Button ghost "Cancel", Button primary "Create User" (Spinner + "Creating..." while busy)
    Toast (useToast: success and error sentences after each write)
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

The rendering is the live rebuild admin-users-console/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md section 3). The page's one read and five writes are props (users, status, error, search, filter, createOpen, creating, draft); the tree is the frontmatter's; the sample rows are seven fictional accounts at example.com with two admins, one inactive account, three client statuses and one voucher line, so every branch of every column renders. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively as the dashboard demo does) with an admin viewer, so the agency group is on the rail, "Operate" is active and "Users" is highlighted in the column, as /admin/users resolves through activeDomain (nav.tsx domain admin, paths at 314 to 324).

States (rendering.states, in order): default (seven rows, the counts 7 / 6 / 2, the filter on "All Users"), loading (the spinner screen the page returns before the shell, no chrome, height 360), error (the red card over an empty table, the fallback sentence), no-match (a search term with no hit: "No users found"), admins-only (the Select on "Admins Only", two rows, "Revoke Admin" in red on each), create-open (the dialog filled with a fictional draft, the password field empty), create-busy (the same dialog with the submit in flight: Spinner and "Creating..."). The shell-wrapped states declare viewport: 1440, so the host lays the page out at the app's desktop width and scales it into the column; the loading state flows.

Tokens and classes as the source uses them: bg-paper ground and text-ink from the shell; Card glass for the stat, filter, error and table cards; bg-primary-100 with text-primary-600 on the users icon, bg-green-100/text-green-600 and bg-blue-100/text-blue-600 on the other two (Tailwind palette colours, not S1 tokens, Gotchas); the quota bar bg-stone-200 with bg-primary-500 fill; text-green-600 on the voucher line; text-red-600 on the revoke action; border-red-200, bg-red-100, text-red-600, text-red-800 on the error card; the dialog bg-black/50 backdrop-blur-sm, rounded-2xl, shadow-2xl, border-line; the row select and the dialog's status select in border-stone-300 text-stone-800 with focus:ring-primary-500.

Responsive: the stat grid is one column below sm and three above; the filter card stacks the form over the Select below md; the table has no responsive treatment and overflows the card horizontally inside overflow-hidden: inside the shell at 1440 px the content column is about 1020 px wide and the second action of the Actions column is already clipped, which the demo's default frame shows as the app does (Gotchas); the shell hides its sidebar below sm. Empty state: "No users found" in the table body, the counts at 0. Error state: the red card, table empty. Loading state: the bare spinner on first load only; a refetch after a write keeps the table and shows nothing (there is no row-level busy state).

What the demo deliberately does not render: the two window.prompt and the window.confirm steps (native dialogs; the buttons are inert), the toasts (useToast needs ToastProvider, which the docs host does not mount; the sentences are listed under API surface), the network refetch after a write, and the AdminRoute redirects (a demo has no router history to redirect through; the frame shows the admin's view only).

Security & tenancy

What the browser may hold: S2's tokens (never a key: the page mints none and receives none; POST /create-user returns no credential and the optional password leaves in the request body once), the fetched rows (email, username, tier, quotas, flags of every account, which is operator data and stays in memory), and the dialog draft including a typed password until submit. import.meta.env.VITE_API_URL is the only baked value.

Origin and CSRF: the api singleton sends the Bearer header, not a cookie, unless VITE_USE_COOKIE_AUTH is on (api.ts 20 to 28), so a cross-site form post carries no credential; under the cookie flag the same-site cookie rules of S5 apply. Surface gating: the admin router is LABS_SURFACES (B16), so a prima_* session is 403 at the gate before AdminUser runs, and the SPA's PrivateRoute already refuses a session whose /me.surfaces lacks labs (S2); AdminRoute adds the isAdmin check on top.

Hidden by CSS versus enforced: nothing here is hidden by CSS; the rail entry and the route are not rendered for a non-admin (adminOnly filter, AdminRoute), and the API enforces on every call. The row actions are visible on the admin's own row too (the super-admin guard is the server's, B16), so the console lets an admin attempt self-revocation and shows a fallback sentence when refused. Attacker-controlled inputs the page sends: search (parametrised ILIKE server-side), token_amount (parsed with parseInt, NaN when the prompt held text, which the route refuses as not positive), reason (free text into admin_actions.details), the draft's strings (bounded by the columns server-side). What the page renders from the server unescaped: nothing; React escapes every string, and no dangerouslySetInnerHTML is used.

Tests

  • Colocated with the demo: admin-users-console/demo.test.tsx (vitest, jsdom, @testing-library/react): the three counts equal the rows' totals; "Revoke Admin" appears once per admin row and "Make Admin" once per other row; the loading state renders no rail and the error state renders the fallback sentence over "No users found"; the dialog's password input is type="password", empty, autocomplete="new-password"; no state's text matches a key or JWT shape. Passed 5 of 5 on 2026-09-12 with npx vitest run --config <a config whose include is modules/identity-auth/ui/*/demo.test.tsx>; the root vitest.config.mjs include covers packages/*/test and test/ only (Gotchas). The file registers afterEach(cleanup) because the root runs with globals: false.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs identity-auth/ui/admin-users-console renders the seven states with renderToString and fetch stubbed.
  • Labs at fd8558f has no unit test of AdminUsers.tsx (grep -rl AdminUsers frontend/src --include='*.test.*' is empty) and no Playwright spec for /admin/users; e2e/critical-flows.spec.ts (115 to 121) only asserts that an anonymous GET /api/admin/users is 401 or 403 and never opens the page. Backend coverage of the routes is B16's (test_desk_agency.py, test_admin_api_key_scopes.py, the surface-scoped walk).
  • What a new app adds: a jsdom test with api mocked that (a) renders the fallback and the server's sentence when data.error is set (once the read is fixed), (b) asserts confirm was called before appoint and the request did not leave when it returned false, (c) asserts the dialog posts undefined for empty optional fields; and a Playwright spec that signs in as an admin, searches, appoints the seeded owner, sees the toast, and checks GET /api/admin/audit-log?limit=1 shows appoint_admin.

Deploy & ops

Build target: the Labs Vite bundle (S2 Deploy & ops); the page is a lazy chunk (App.tsx line 130, lazy(() => import('./pages/AdminUsers'))). Env baked at build: VITE_API_URL (the API origin, api.ts 14) and VITE_USE_COOKIE_AUTH. CSP and CORS: nothing of its own; the admin routes answer on the API origin under the SPA's existing allowance. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate; the routes are B16's and in the baseline. Argus: a synthetic admin session calling GET /api/admin/users-list?limit=1 returning 200 is the page's liveness; the page itself has no health signal beyond the fallback sentence.

Gotchas

Failed read: the red card with the detail string, an empty table below error server rendered, hydrated in view

User Management

View and manage platform users

Total Users

0

Active Users

0

Admins

0

Failed to load users

User
Status
Client Status
Subscription
Tokens
Actions
No users found
  • 2026-09-12 (measured at fd8558f): the five catch blocks read err.response?.data?.detail (85, 111, 126, 137, 160); the Labs envelope has no detail key (create_error_response, schemas/error_schemas.py 130 to 136; the handler at app_fastapi.py 774 to 788 renders a string detail as error). So "User is already an admin", "Super admin cannot revoke their own privileges", "User with email <e> already exists", "Token amount must be positive" and "Admin access required" all reach the screen as "Failed to ...". Fix: read data.error ?? data.detail ?? data.message (the commerce service's extractError, S7 Frontend) in one helper, and render the sentence.
  • 2026-09-12 (measured at fd8558f): get_all_users returns [] on any exception (admin_service.py 376 to 378), so a database fault renders as the empty table with "No users found" and the counts at 0, not as the red card; the red card appears only for a network failure or a 4xx. Fix on the server side (B16): let the exception reach S7; on the client, treat [] with skip: 0 and no search as a state worth a sentence ("No accounts yet") rather than the same words as a miss.
  • 2026-09-12 (measured at fd8558f): the console renders "Inactive" (196 to 198) but has no control that deactivates or reactivates an account; PATCH /users/{id}/status has no caller in frontend/src. The inventory row's "status and role" is the badge only. A rebuild adds the switch with a confirmation, and the server side adds the audit row it lacks (B16 Gotchas).
  • 2026-09-12 (measured at fd8558f): the inventory tree names "impersonate", "agency access", "grants per surface as a column" and "audit row after every action" on this page; none is there. grep -rl impersonate frontend/src is empty; users-list returns no surfaces (B16 Gotchas). The frontmatter tree is the measured one; the four are the rebuild's additions, each with its route in B16.
  • 2026-09-12 (measured at fd8558f): the three stat cards are counts over the fetched page (limit: 100, 77; 314 to 316), so with more than 100 accounts, or with a search or filter applied, "Total Users" is the size of the current result set. A rebuild renders the route's own totals or labels the card "shown".
  • 2026-09-12 (measured at fd8558f): the token amount and the reason are collected with window.prompt (97, 100) and the appoint or revoke confirmation with window.confirm (116); the client-status select PATCHes with no confirmation (130 to 139). Native dialogs cannot be styled, block the thread and are refused by some embedded browsers; a rebuild uses a dialog built from the atoms (the create dialog's own markup) and asks before the status change too. D2 (2026-09-11) makes the add-tokens action itself legacy: the voucher-era credit is replaced by the prepaid ledger, and a rebuild drops the column and the action.
  • 2026-09-12 (measured at fd8558f): the page returns the spinner screen when loading && users.length === 0 (318), which is before <AdminNavigation>, so the first paint of /admin/users has no shell and the rail jumps in when the rows arrive. Rendering trap for the verifier: the loading state is compared against a route with no chrome, and the demo's loading state deliberately has none.
  • 2026-09-12 (measured at fd8558f, seen on the docs host the same day): the table is not responsive (no overflow-x-auto wrapper; the card is overflow-hidden, 440). Inside the shell the page's column at a 1440 px viewport is about 1020 px (1440 less the 292 px sidebar, the shell's lg:px-10 and the page's own px-6), and six columns at size="md" need more, so the second button of the Actions column is clipped with no scrollbar; narrower, more columns go. The shell already hides its sidebar below 640 px, so a phone shows a table with the last columns missing. Fix: wrap the DataTable in overflow-x-auto, or drop the voucher-era Tokens column (D2). Rendering trap: a 390 px frame of this page is not a rendering of the console; the recipe's states declare viewport: 1440, and the clipped action is the app's, not the demo's.
  • 2026-09-12 (measured at fd8558f): the stat-card icons and the error card use Tailwind palette colours (bg-green-100, text-blue-600, border-red-200, bg-red-100, text-red-800) and the row select border-stone-300 text-stone-800, against S1 rule 1 (tokens only); the alchemy-ui Badge variants beside them carry the same meanings on tokens. A rebuild swaps the four palette colours for success, info, error tokens.
  • 2026-09-12 (measured at fd8558f): the dialog is fixed inset-0 with no focus trap, no role="dialog", no Escape handler and no aria-modal (455 to 456); the close button has no accessible name (459 to 464). The demo adds aria-label="Close" and positions the dialog absolutely to the frame; everything else is as shipped.
  • 2026-09-12 (measured at fd8558f): the row's client-status select is a native <select> (209 to 221) while the filter beside it is the Select atom; and the dialog's status select is native too (546 to 554). The atom portals its list to document.body (Select.tsx 138), which is why the demo host scopes its CSS on the body id (RENDERING.md section 1.2); a native select needs nothing but also takes none of the tokens.
  • 2026-09-12 (measured at fd8558f): toLocaleString() on the three numbers (243, 255, 262) formats in the browser's locale, so the same row reads 100,000 on one machine and 100'000 on another, and a server-rendered demo would not hydrate cleanly; the demo groups by hand with a comma. A rebuild formats with one fixed locale.
  • 2026-09-12 (kit): the root vitest.config.mjs include is packages/*/test/** and test/**, so a colocated demo.test.tsx under modules/ does not run under npm test and must be run with a config that names it (Tests). Fix: add modules/**/ui/*/*.test.tsx to the include at the root, so every demo's colocated test runs with npm test.

Reference implementation

Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-12), the deployed line feat/service-desk plus the token cleanup. Paths relative to the repo root:

  • frontend/src/pages/AdminUsers.tsx (591 lines): imports (1 to 19), User (21 to 35), the status options and badge map (37 to 47), state (49 to 68), fetchUsers (74 to 89), handleSearch (91 to 94), handleAddTokens (96 to 113), handleToggleAdmin (115 to 128), handleClientStatusChange (130 to 139), handleCreateUser (141 to 164), the six columns (167 to 311), the counts (314 to 316), the loading return (318 to 327), the page (329 to 451), the dialog (454 to 586).
  • frontend/src/components/AdminRoute.tsx (41 lines): the three branches (16 to 37).
  • frontend/src/components/AdminNavigation.tsx (22 lines): the delegator to AppShell with the C3 note.
  • frontend/src/components/nav.tsx: the admin domain (307 to 336), GROUP_LABEL (339 to 347).
  • frontend/src/App.tsx: the route (764 to 769), the lazy import (130).
  • frontend/src/services/api.ts: the singleton and its interceptors (16 to 80); no wrapper for the admin routes, the page calls api.get, api.post, api.patch directly.
  • frontend/src/contexts/AuthContext.tsx: isAdmin from is_admin (104, 139, 203).
  • backend/api/routers/admin_router.py: get_users_list (1439 to 1459), create_user (1462 to 1591), appoint_admin (1329 to 1361), revoke_admin (1364 to 1396), add_tokens (1399 to 1436), update_client_status (1019 to 1099); backend/services/admin_service.py: get_all_users (315 to 378).
  • backend/app_fastapi.py 724 to 788 and backend/schemas/error_schemas.py 112 to 136: the envelope the page misreads.
  • The demo: modules/identity-auth/ui/admin-users-console/demo.tsx and demo.test.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.tsx.

Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads; the swallowed list exception; no status switch, no impersonation, no agency grant, no grants column; counts over the page; native prompt and confirm, and no confirmation on the status change; the shell-less first paint; the non-responsive table; palette colours and native selects; locale-dependent number formatting; the dialog without dialog semantics.

Acceptance checks

From alchemy_labs/frontend at fd8558f with the harness up (tooling/verify/labs-local.sh up; an admin session needs the seeded owner appointed through POST /api/admin/appoint-admin by the first admin, or dev-login in development, B16 Acceptance check 6), or from a new app built to this recipe:

  1. grep -c "data?.detail" src/pages/AdminUsers.tsx prints 5 at fd8558f and 0 after the fix; grep -c "window.confirm\|prompt(" src/pages/AdminUsers.tsx prints 3 at fd8558f and 0 after.
  2. grep -n "api\.\(get\|post\|patch\)(" src/pages/AdminUsers.tsx prints exactly five lines naming /api/admin/users-list, /api/admin/add-tokens, /api/admin/revoke-admin or /api/admin/appoint-admin, /api/admin/users/${userId}/client-status, /api/admin/create-user.
  3. Browser walk-through, signed in as an admin: (a) open /admin/users, observe a bare spinner with "Loading users..." and then the shell with "Operate" active on the rail and "Users" highlighted in the column, the three counts, the table; (b) type part of a fictional test account's address and press Enter, observe the table reduced to the match and "Total Users" equal to the number of rows shown; (c) type a string that matches nothing, observe "No users found" and the counts at 0; (d) switch the Select to "Admins Only", observe only rows with the "Admin" badge and "Revoke Admin" in red; (e) click "Make Admin" on a non-admin test account, dismiss the confirm, observe no request in the network panel; confirm, observe the toast "Admin privileges granted successfully!" and the badge on the refetched row; click it again, observe "Revoke Admin"; (f) click "Create User", observe the submit disabled until an address is typed, submit with the password empty, observe the dialog close, the new row, and in the backend SELECT password_hash IS NULL FROM users WHERE email = ... printing t; (g) submit the same address again, observe the toast "Failed to create user" (at fd8558f) or the server's sentence "User with email ... already exists" (after the fix); (h) sign in as a non-admin, open /admin/users, observe the redirect to / and no "Operate" entry on the rail; call GET /api/admin/users-list with that session, observe 403 "Admin access required".
  4. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs identity-auth/ui/admin-users-console prints ok for the seven states and 0 failures; the colocated test prints 5 passed.
  5. Verifier: node tooling/verify/compare.mjs identity-auth/ui/admin-users-console --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (the session an admin's) writes a report with page_errors: []; mismatch_ratio is read for the header, the counts row and the table's first column only, since the app's rows are the harness's accounts and the demo's are sample data, and the spinner state is excluded (it is a different route state).

Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.