Skip to content

feat(admin): mint API tokens for a staff owner with a role and lifetime, list and revoke them - #1992

Merged
DavidCockerill merged 20 commits into
stagefrom
david/1985-admin-api-tokens
Oct 23, 2026
Merged

DavidCockerill merged 20 commits into
stagefrom
david/1985-admin-api-tokens

Conversation

@DavidCockerill

@DavidCockerill DavidCockerill commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

The Admin → API Token page is one "Generate token" button: a one-hour token acting as the signed-in admin, never listed, never revoked. Central manager now mints staff tokens for a named owner with a role and a role-capped lifetime, and lists and revokes them (HarperFast/central-manager#973, PR HarperFast/central-manager#975). The page had to catch up.

🤔 Is a whole page warranted, or would the button do?

Yes: the owner, the role and the lifetime are the point of HarperFast/central-manager#973, and a token nobody can see or revoke is what it replaces. The page is the Studio half of that issue (#1985) and ships with it; the old empty-body mint still works on the server, so nothing here is urgent on its own.

💡 Solution

  • A mint form: owner (email search over Admin/User, "Me" shortcut), role (least privilege first), name, and a lifetime in hours bounded by the role's cap mirrored from central manager.
  • The show-once card keeps the same cache hygiene; another mint waits until the card is dismissed.
  • A token table shows owner, role, minter, dates and status, with confirmed Revoke for active tokens.
  • The page shows to apiToken:self (every staff role), the form only to apiToken:create: staff who cannot mint still see and revoke their own tokens.

🤔 Why does the role picker offer every role when the server refuses one wider than the owner's?

Nothing central manager exposes says which staff role another account holds; exposing it on Admin/User is a central manager follow-up. So the picker offers every role, defaults to read-only, and the server's refusal, which names the owner's role, is shown under the Role field. One field on the user record reverses this.

🤔 Which review findings were accepted rather than fixed?

Sign-out mid-mint can leave an unheld token live until it expires or idles 5 days: the clean-up revoke has no session; the token stays listed and revocable. Labels cost one cached lookup per other staff id. post-review delta: the 404 revoke wording, comment trims and the starts_with query, not re-reviewed — narrow fixes inside code every full pass read.

⚖️ Alternatives

  • Reusing the organization service-account token pieces: they live on the unmerged david/576-org-api-tokens branch, not on stage; fold together once both land.
  • A modal instead of an inline form: the page has nothing else on it.
  • Lifetime in days: the admin cap is 12 hours.
  • Let the server default the role to the owner's: then the lifetime field has no cap to show, and the default is the owner's widest role, not the narrowest useful one.

🔧 Changes

Product and architecture tour

Product and architecture tour

What the page does now

Before After
One "Generate token" button: a one-hour token acting as the signed-in admin, never listed, never revoked. An admin mints a token for a named staff owner with a role and a role-capped lifetime; every staff member sees their tokens with a status and can revoke them; admins see everyone's.

A token acts as its owner with the role it was minted with, for at most the role's lifetime, and is refused after 5 days unused; the secret is shown once; the form moves it into page state and clears the mutation cache in the same step.
The page shows only to an API that sends apiToken:self, the permission that ships with the endpoints it drives.

The mint, from form to list

From Mint to the show-once card

The secret goes to the page's state before the mutation forgets it; a token minted after the page was left gets a revoke attempt, and the admin is told when that attempt fails.

sequenceDiagram
    participant Admin
    participant Form as MintStaffApiTokenForm
    participant CM as central manager
    participant Page as ApiTokenIndex
    Admin->>Form: owner, role, name, lifetime
    Form->>CM: POST /Admin/ApiToken/
    alt 403 role wider than the owner holds
        CM-->>Form: refusal naming the owner's role
        Form->>Admin: message under the Role field
    else minted
        CM-->>Form: token with its secret
        Form->>Page: onMinted (page state)
        Form->>Form: reset() the mutation
        Page->>Admin: show-once card
        Form->>CM: GET /Admin/ApiToken/ (list refresh)
    else minted after the page was left
        Form->>CM: DELETE /Admin/ApiToken/:id
        Form->>Admin: warning toast
    end
Loading

Lifetime caps mirror central manager's STAFF_API_TOKEN_LIFETIMES: fabric_readonly up to 14 days (default 14), fabric_support and fabric_staff up to 7 days (default 7), fabric_admin up to 12 hours (default 1). The server refuses longer lifetimes; the form caps the input.

Choosing an owner

The user table holds customers and staff and offers no "ends with" match. The picker reads GET /Admin/User/?email=starts_with=<prefix> page by page until it finds staff, keeps only the staff domain, and asks for more typing when it runs out of pages or hits its limit. Results match the settled prefix; Enter never reaches the mint form; closing cancels the search.

Central manager does not expose another account's staff role. The role list offers every staff role; the server's refusal names the owner's role and appears under the Role field.

The list

Statuses: Active authenticates and can be revoked here; Idle went unused for 5 days before its expiry and is refused; Expired reached its expiry; Revoked was revoked here and stays listed for 30 days. Owner and minter ids are rendered as addresses through GET /Admin/User/:id, one cached lookup per distinct id other than the signed-in account; a failed lookup shows the id.

Files

✅ Verification

  • End-to-end route, run: Studio from this branch against the local Fabric rig's central manager on david/staff-api-tokens; every step below passed, and it caught the *-vs-starts_with query bug fixed here.
Live smoke steps
  • As a fabric_admin: minted a read-only token for a fabric_readonly colleague picked by email; the card named the owner and role, the row appeared Active, and a second mint was held until Done.
  • Asked for a fabric_admin token for the same owner: "A fabric_admin token would exceed the owner's fabric_readonly role" appeared under the Role field.
  • Revoked the token through the confirm: DELETE /Admin/ApiToken/:id answered 200 and the row flipped to Revoked.
  • As the fabric_readonly account: the rail shows the page, the list is "Your tokens" with only that token, and there is no mint form.
  • The deployed Harper line (5.3) read a trailing * as a literal, so the owner search matched nobody; it now sends email=starts_with=<prefix>, which also works on main.
- `tsc -p tsconfig.app.json`, `oxlint`, and `dprint check` clean; full `vitest run` passes except for 6 failures in `LogsFiltersForm.test.tsx` and `beforeSend.sdk.test.ts` that reproduce on untouched `origin/stage` (2b0b41c).
Unit tests

Related PRs: #1780 overlaps (StaffPermission union), #1975 overlaps (StaffPermission union, admin visibility fixture), #1988 overlaps (StaffPermission union, admin visibility fixture), #1989 overlaps (admin visibility fixture)

🤖 Generated with Claude Code

Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(lost); declined=cursor-composer,cursor-kimi,cursor-muse,muse; rounds=9; full=5 @ a064a1e

Review-Attention: study ~20m (critical: mintTokenSchema.ts; decisions: do-less-alternative, auto-revoke-unseen-token, client-side-staff-filter, legacy-api-hides-token-page, role-refusal-via-403) @ a064a1e

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive management interface for Admin/Staff API tokens, replacing the previous simple token generation mechanism. It adds features to mint tokens with specific roles, owners, and lifetimes, as well as capabilities to list, filter, and revoke active tokens. Key additions include the MintStaffApiTokenForm, StaffUserPicker, and StaffApiTokenTable components, along with corresponding React Query hooks, Zod validation schemas, design documentation, and extensive unit tests. I have no review comments or feedback to provide as no issues were identified.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 80.65% 15526 / 19250
🔵 Statements 80.58% 16488 / 20461
🔵 Functions 76.1% 4179 / 5491
🔵 Branches 75.01% 12046 / 16059
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/admin/routes.ts 69.23% 100% 55.55% 69.23% 33-34, 40-41, 47-48, 54-55
src/features/admin/apiToken/index.tsx 95.23% 60.97% 92.85% 96.96% 68, 89
src/features/admin/apiToken/mintTokenSchema.ts 100% 100% 100% 100%
src/features/admin/apiToken/staffTokenRoles.ts 100% 100% 100% 100%
src/features/admin/apiToken/components/MintStaffApiTokenForm.tsx 93.93% 91.42% 86.66% 93.65% 115-118, 248
src/features/admin/apiToken/components/MintedTokenCard.tsx 0% 100% 0% 0% 17-43
src/features/admin/apiToken/components/StaffApiTokenRowMenu.tsx 86.66% 100% 83.33% 85.71% 30-31
src/features/admin/apiToken/components/StaffApiTokenTable.tsx 100% 84.21% 100% 100%
src/features/admin/apiToken/components/StaffUserCell.tsx 100% 78.04% 100% 100%
src/features/admin/apiToken/components/StaffUserLabel.tsx 0% 0% 0% 0% 10-17
src/features/admin/apiToken/components/StaffUserPicker.tsx 91.66% 88.09% 84.21% 92.59% 68-69, 132, 147
src/features/admin/apiToken/mutations/useGenerateApiToken.ts 100% 100% 100% 100%
src/features/admin/apiToken/mutations/useRevokeApiToken.ts 100% 100% 100% 100%
src/features/admin/apiToken/queries/getStaffApiTokens.ts 100% 100% 100% 100%
src/features/admin/apiToken/queries/staffUsers.ts 100% 100% 100% 100%
src/features/admin/components/AdminShell.tsx 33.33% 61.9% 60% 42.85% 51-73
Generated in workflow #2433 for commit a064a1e by the Vitest Coverage Report Action

@DavidCockerill
DavidCockerill force-pushed the david/1985-admin-api-tokens branch from ea7d9dc to 7d160a0 Compare October 21, 2026 18:54
@DavidCockerill
DavidCockerill marked this pull request as ready for review October 21, 2026 18:55
@DavidCockerill
DavidCockerill requested a review from a team as a code owner October 21, 2026 18:55
@DavidCockerill
DavidCockerill force-pushed the david/1985-admin-api-tokens branch from 7d160a0 to e1dff41 Compare October 21, 2026 18:58

@dawsontoth dawsontoth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bye bye, conflicts!

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay!

🤖 Reviewed with Codex

Comment thread src/features/admin/apiToken/components/MintStaffApiTokenForm.tsx Outdated
Comment thread src/features/admin/apiToken/components/MintStaffApiTokenForm.tsx Outdated
DavidCockerill and others added 12 commits October 22, 2026 09:31
…me, list and revoke them

Replaces the single "Generate token" button with central manager's new staff
token model (HarperFast/central-manager#973): an admin picks the owner by email,
a role, a name and a lifetime bounded by the role's cap; the token is shown once
and kept out of the mutation cache as before. Every staff role sees its own
tokens with their status and can revoke them; admins see everyone's.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nothing central manager exposes says which staff role another account holds,
so the mint's 403 for a role wider than the owner's, which names the owner's
role, is placed under the role field; every other failure toasts as before.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…past customers

Review round 1: matches now belong to the prefix the typing settled on and Enter
never reaches the form; the staff search reads pages until staff turn up among
customers sharing the prefix; a failed user lookup falls back to the id without
a toast; the rail shows the page on apiToken:self or apiToken:create; the
mutation-cache note no longer claims sign-out keeps the cache; dead exports and
narrating comments removed; a form test covers the mint path and the refusal.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tcome, revoke unseen tokens

Review round 2: a refusal clears when the owner or role changes; the list
refreshes on settle so a mint whose answer was lost still shows; a token minted
after the page was left is revoked; the picker only searches while open, Enter
never submits even after Escape, and a search that ran out of pages says to
type more; the signed-in account is named without a lookup.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…onestly, stop stale paging

Review round 3: the "type more" hint shows whenever the search ran out of
pages, matches or not; an unseen-token revoke whose answer was lost goes
through the shared error handler and refreshes the list; clearing the owner
no longer flags an empty owner; the search honours the query's abort signal.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oken stays live

Review round 4: closing the picker cancels the search in flight (disabling
the query alone does not abort it); a refused unseen-token revoke says the
token is still live and where to revoke it, while a lost answer keeps the
shared wording; a mint that failed after the page was left still toasts.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…apiToken:self

Closing review round: the apiToken:create fallback would show the page on a
central manager that also lacks the list endpoint and the owner-aware mint, so
the form would mint the admin a self token whatever it asked for. The page
waits for the permission that ships with the API it drives. Narrating comments
trimmed.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 6: an API without staffPermissions at all predates the token API this
page drives, so the fallback that grants fabric_admin every permission must not
show it. Duplicate test comments removed.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closing round: a search that stops at 20 staff with a full page behind it is
reported as truncated, so the 21st colleague is not read as nonexistent.
Comments that duplicated the design note trimmed.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 8: minting again while a card was showing replaced that secret before
it was copied, leaving a live token nobody could use. The form now waits for
the card to be dismissed. Three narrating comments trimmed.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oke finds nothing

Round 9: a 404 on the post-unmount revoke meant "still live; revoke it from
the list", sending the admin after a token that does not exist. Narrating
comments trimmed.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g wildcard

Found by the live smoke against the rig: the deployed Harper line (5.3) reads
a trailing `*` as a literal, so the owner picker never matched anyone. The
FIQL `email=starts_with=<prefix>` form works there and on main.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DavidCockerill
DavidCockerill force-pushed the david/1985-admin-api-tokens branch from a8b5704 to aab467a Compare October 22, 2026 13:31
DavidCockerill and others added 7 commits October 22, 2026 10:33
…nd shorten the dates

The table overflows sideways on a normal viewport, which scrolled the one
control off the right edge; it now leads the row, and the three timestamps
drop their seconds.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s and an icon revoke

Name and role share a cell, the minter sits under the owner, Created, Expires
and Last used read as a stamp with "in 13 days" beneath, ended rows dim as a
whole, and Revoke is a leading icon button with a tooltip.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…out a sideways scroll

The cell default is nowrap, so the table could only overflow. Cells that hold
prose now wrap, the owner folds under the token name below lg, Last used and
Created leave at xl and 2xl, and Revoke is a labelled button again.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…u for Revoke

Token name over its masked id, owner and minter as an initial with name and
address, role as a coloured dot, a date with its relative reading, a status
badge, and a "⋮" menu that copies the id or revokes a live token behind the
confirm. The owner folds under the token below lg; Last used and Created leave
at xl and 2xl, so the table never scrolls sideways.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s wrap

The role column joins the owner under the token name below md, dates may
wrap below sm, and a user's name and address wrap anywhere rather than
truncating, which had fixed the column's minimum at the whole name.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…such

Cells had no horizontal padding, so the first column sat on the border. A
404 on the owner or minter lookup now reads "Removed account" over the id
instead of a bare question mark; other failures still show the id.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…all a 404 clean-up done

Review: two submits entering the handler before the first answer sent two
mints and replaced the first secret; a synchronous lock now holds until the
response is handled. A 404 on the post-unmount revoke is reported as an
unconfirmed clean-up naming the token and owner, since central manager
answers 404 for another owner's token when the caller lacks apiToken:create.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DavidCockerill

Copy link
Copy Markdown
Member Author

Addressed both review findings in bb63d30: a synchronous submit lock on the mint form (with a racing-double-submit test that fails without it), and a 404 on the post-unmount revoke now reported as an unconfirmed clean-up naming the token and owner rather than "already gone". Gates are green; re-requesting review.

🤖 Generated by Anthropic Claude (Fable 5.1) with Claude Code; posted via @DavidCockerill.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed with Cursor-grok

Comment thread src/features/admin/apiToken/index.tsx Outdated
Comment thread src/features/admin/apiToken/index.tsx Outdated
…y what the row shows

Review: a background refresh that failed replaced the table, and its revoke
menus, with an error; the last known rows now stay under a warning with
Retry. The filter box matches the masked and full token id and the owner's
and minter's address and name, resolved through the cells' own lookups.

Refs #1985

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@DavidCockerill

Copy link
Copy Markdown
Member Author

Addressed the two remaining review findings in a064a1e: a failed background refresh keeps the last known tokens (and their menus) on the page with a Retry, and the filter box matches the shown id and the owner's and minter's address. Gates are green; re-requesting review.

🤖 Generated by Anthropic Claude (Fable 5.1) with Claude Code; posted via @DavidCockerill.

@DavidCockerill
DavidCockerill added this pull request to the merge queue Oct 23, 2026
Merged via the queue into stage with commit 8772f9e Oct 23, 2026
3 checks passed
@DavidCockerill
DavidCockerill deleted the david/1985-admin-api-tokens branch October 23, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants