Service accounts - #1989
Conversation
6c847e6 to
f0bebb8
Compare
d60c562 to
4db5bdd
Compare
96f4766 to
2329fe9
Compare
❌ Code HealthIntroduces 49 Unused files
|
e89b75d to
8f05c41
Compare
9334471 to
d1c8ac7
Compare
1d25f09 to
b326b3e
Compare
Service accounts are non-human identities that hold their own grants and API keys, so automation can authenticate as a durable principal with a scoped capability set. This adds the admin UI for managing them, replacing the Admin > API tab. Admin > Service accounts (`/admin/serviceAccounts`): - List of accounts as cards, with an empty state that offers both direct and guided creation - Details view (`/admin/serviceAccounts/details`) with an API keys section and a grants section, each supporting create and revoke/remove - CreateDialog for the full account creation flow (name, capability selection, prefix grants, key lifetime), plus CreateApiKeyDialog and GrantDialog for adding to an existing account - SecretRevealModal shows a newly minted key once; the value is deliberately not cached - ExpiryWarning and UsageIndicator surface key lifetime and usage at a glance - Accounts get a stable monogram avatar color derived from their name (`src/utils/stableColor.ts`), and generated account names come from an alliterative word list (`src/utils/alliterate.ts`) GraphQL (`src/api/gql/serviceAccounts.ts`) covers the serviceAccounts query and the createServiceAccount, createApiKey, revokeApiKey, revokeAllApiKeys, addServiceAccountGrant and removeServiceAccountGrant mutations, with URQL cache entries for ServiceAccount, ServiceAccountApiKey, UserGrant, and a null-keyed CreateApiKeyResult so the one-time secret stays uncached. The personal refresh-token UI moves out of the admin area to `/settings/personalTokens`, reachable from the user menu, and the flowctl auth flow gets its own page at `/flowctl/accessToken`.
List: cap the account cards at 300px wide (`repeat(auto-fill, minmax(240px, 400px))` for the main grid, `minmax(200px, 300px)` for the "No access" compacts) so they keep a consistent size instead of stretching to fill the row. CreateDialog: the full-name preview's color is derived from the name, so it changed on every keystroke. The text still updates immediately, but the color now settles 300ms after typing stops and crossfades over a second. Adds `useDebouncedValue`. Details: show the selected account's leaf name as a trailing breadcrumb segment in the content header. `TopBar` gains a `headerDetail` field, set through `usePageTitle` and rendered by `PageContainer` after a "/" separator; it holds a resolved string rather than a message id, since entity names aren't translatable. The details page reads the name from the URL param so the breadcrumb is populated on first render rather than when the query lands, and the header segment is pluralized to "Service Accounts" so the trail names the parent section. Also adds "mercurial" to the alliterative name library.
Resetting in a useEffect on open painted one frame of the previous session's state first: the leftover description enabled the Create button until the reset landed. Seeding during render, the pattern CreateServiceAccountDialog already uses, makes the first visible frame the fresh one.
One Dialog now hosts both phases: the form, then the one-time secret reveal in its place once a key is minted. SecretRevealModal becomes SecretReveal, a content-only component that owns the copied gate and unmounts with the dialog, so the gate needs no reset. The reveal phase drops the Dialog onClose, so backdrop clicks and Escape cannot dismiss an unseen key. The dialog exits only when the session is over, which makes reset timing trivial: one unconditional onExited handler restores the form, and no visible frame ever holds a reset. The reveal reads a snapshot taken at mint time - the expiry string is fixed when the key is created, and the live form state stays free to be cleared.
Wraps the dialog's phase content in an AnimatedHeight box using the mechanism from WizardContent: a ResizeObserver keeps an explicit height on the wrapper, and the height transitions only while a phase swap is in flight, so in-phase resizes like the error alert stay instant. The wrapper renders inside the Dialog and unmounts with it, so a reopened dialog cannot start at the previous phase's height.
BandedDiv generalizes the tucked-band mechanism: a rounded frame whose face sits over a colored band poking out one edge, selected by a side prop. The band takes an optional label, read vertically when the band is on the left or right. With an onClick set the frame becomes a ButtonBase and the band rests at a desaturated color, coming up to full color on hover. The desaturate helper and rest-saturation constant move with it, exported for owners that hold other surfaces (like AccountCard's border) to the band's color scheme. AccountCard keeps its exact look: bottom band on expiry, no band otherwise, border and band sharing the expiry color and rising together on hover.
The no-band state moves from an omitted side to an omitted bandColor, so AccountCard now passes side=bottom unconditionally and gates the band on the expiry color.
A left or right band now rotates the whole label node 90 degrees with a transform, so icons turn with the text. A transform never affects layout - the rotated label would keep its wide horizontal box and stretch the band - so RotatedLabel measures the node and swaps its dimensions onto a spacer, centering the rotated content absolutely inside it. The band no longer imposes a row Stack on the label; the node owns its internal layout. That also restores the icon-to-text gap the extraction dropped: the band's Stack spacing never applied inside a fragment label.
A BandedDiv without a band was the component's name telling a lie, so bandColor is now required and the band always renders. AccountCard splits into two explicit frames sharing one face style and content block: a plain ButtonBase card normally, and a BandedDiv with the bottom expiry band when a key is expiring. Also folds in the vertical label rotation direction change to 270 degrees, reading bottom-to-top.
MUI's decomposeColor rejects named CSS colors, so bandColor=tomato threw from getContrastText. The band color now round-trips through a canvas fillStyle, which has the browser normalize any color it can parse to hex or rgba() before the MUI color math. The context resets to black between calls, so an unparseable color degrades to a black band instead of silently reusing the previous call's color.
A bare BandedDiv now renders a padded theme-paper face instead of a transparent one, so the band tuck reads without the caller styling the face. faceSx merges over the default, so existing consumers are unchanged.
The face's default border now matches the service account cards exactly, using the same defaultOutline constant instead of the divider token.
The face's default border now carries the band color the way the service account cards do: desaturated at rest when the frame is interactive, rising to full color on hover together with the band; full color always when static. A label-less band also holds a minimum visible run past the overlap, so it reads as a band rather than collapsing to its padding.
The label node now renders into a slot stretched across the band's visible run whichever side the band is on, and owns its own alignment inside it. Horizontal bands stretch the label through a flex slot. Vertical bands rework RotatedLabel: the spacer stretches along the band, its measured length becomes the rotated node's width, and the label's natural height stays the band's thickness. A label that centers, spreads, or fills behaves identically on any side.
A fragment, string, or array label now renders on a centered row with a small gap between children, caption type, and no wrapping, so an icon-and-text label needs no wrapper Stack. A single element keeps the full-run canvas and owns its own layout and alignment. The default applies in both orientations, since the wrapped label feeds the same slot and RotatedLabel paths.
Merged onto the band over its defaults. The band's text styling (caption type, weight 600, nowrap) moves from the default label wrapper onto the band element itself, so it inherits into any label and a bandSx font size or weight override actually wins.
The only realistic band overrides are label text styles, and a single-element label already owns those. Everything else bandSx could reach is the tuck mechanism or the bandColor system, which callers should not mutate. Bare labels keep the caption/600 defaults inherited from the band.
The expiry label becomes a bare fragment on the band's default centered row, and the frame sx drops what the component now provides: the hover rule raising the face border to the band color is built in, and the grid stretches the card without width 100%. The frame override is down to the no-grants opacity dimming.
b0d9e35 to
f9701ba
Compare
29b738f to
c53f0d8
Compare
5b4a232 to
8bd6084
Compare
8bd6084 to
eacbe89
Compare
Drops the nowrap default so a long label wraps and thickens the band; the vertical path already feeds the wrapped height back through its measurements. Wrapping makes the default row width-constrained, and an SVG's minimum size resolves to zero under flex shrink, so icons in the default row are pinned with flex none to keep them at their given size.
eacbe89 to
7dd5b71
Compare
Summary
New Service Accounts admin section (replaces the CLI-API tab): create accounts with scoped capabilities on catalog prefixes, manage grants, and issue/revoke API keys — key secrets are shown once at creation and can't be retrieved again.
The old CLI-API tab splits into two:
/flowctl/accessTokenpage with a copy-to-clipboard button (token is never rendered on screen);/admin/apiredirects here./settings/personalTokens, linked from the user menu.Service Accounts is reachable as a tab in the existing admin tab bar, alongside Account Access, Settings, and Billing.
Built directly on
main, using the deployedapiKeyservice-account GraphQL API with regenerated GQL types.Test plan
/admin/apiredirects to/flowctl/accessToken; copy works, token not shown