Skip to content

Service accounts - #1989

Draft
GregorShear wants to merge 53 commits into
mainfrom
greg/access
Draft

Service accounts#1989
GregorShear wants to merge 53 commits into
mainfrom
greg/access

Conversation

@GregorShear

@GregorShear GregorShear commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

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 access token/flowctl/accessToken page with a copy-to-clipboard button (token is never rendered on screen); /admin/api redirects here.
  • Personal Tokens — refresh-token management at /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 deployed apiKey service-account GraphQL API with regenerated GQL types.

Test plan

  • Create a service account, issue an API key, and confirm the secret shows once (copyable) and can't be retrieved again
  • Add/remove grants and revoke an API key from the detail view
  • Service Accounts appears in the admin tab bar, and the bar stays visible when navigating between admin pages
  • /admin/api redirects to /flowctl/accessToken; copy works, token not shown
  • Personal Tokens opens from the user menu; refresh tokens create/revoke

@GregorShear GregorShear closed this Jun 3, 2026
@GregorShear
GregorShear deleted the greg/access branch June 3, 2026 02:44
@GregorShear
GregorShear restored the greg/access branch June 3, 2026 02:44
@GregorShear GregorShear reopened this Jun 3, 2026
@GregorShear
GregorShear changed the base branch from main to greg/refresh-tokens June 3, 2026 03:17
@GregorShear
GregorShear force-pushed the greg/access branch 4 times, most recently from 6c847e6 to f0bebb8 Compare June 3, 2026 04:50
@GregorShear
GregorShear force-pushed the greg/refresh-tokens branch 2 times, most recently from d60c562 to 4db5bdd Compare June 4, 2026 03:07
@GregorShear
GregorShear force-pushed the greg/refresh-tokens branch from 96f4766 to 2329fe9 Compare June 17, 2026 14:45
Base automatically changed from greg/refresh-tokens to main June 18, 2026 03:23
Comment thread src/components/admin/ServiceAccounts/CreateDialog.tsx Fixed
@GregorShear
GregorShear changed the base branch from main to greg/dash/nav July 7, 2026 03:29
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

❌ Code Health

Introduces $\textcolor{red}{4}$ dead-code issues (removes $\textcolor{green}{1}$).

49 Unused files $\textcolor{red}{+1}$

File imported nowhere — delete (or import) it.

$\textcolor{red}{+}$ src/components/shared/buttons/CopyValueButton.tsx
     src/hooks/useDelay.ts
     src/hooks/useDraft.ts
     src/pages/NoGrants.tsx
     src/pages/OAuth.tsx
     src/services/encryption.ts
     src/types/global.ts
     src/types/vitest.ts
     src/components/graphs/TaskHoursByMonthGraph.tsx
     src/components/tables/Link.tsx
…and 39 more

67 Unused exports $\textcolor{red}{+2}$ $\textcolor{green}{-1}$

Exported symbol with no references outside its own file — un-export it, or delete it if unused entirely

$\textcolor{red}{+}$ src/api/gql/serviceAccounts.ts : SERVICE_ACCOUNT_FRAGMENT
$\textcolor{red}{+}$ src/components/shared/BandedDiv.tsx : BANDED_DIV_FACE_CLASS
$\textcolor{green}{-}$ src/context/Theme.tsx : logoColors
     src/context/Theme.tsx : intensifiedOutlineThick
     src/context/Theme.tsx : tableAlternateRowsSx
     src/context/Theme.tsx : draggableChipIconSx
     src/context/Theme.tsx : hiddenButAccessibleInput
     src/context/Theme.tsx : primaryColoredBackground_hovered
     src/context/Theme.tsx : detailsPanelBgColor
     src/context/Theme.tsx : menuBackgroundColor
…and 58 more

31 Unused exported types $\textcolor{red}{+1}$

Exported type with no references outside its own file — un-export it, or delete it if unused entirely

$\textcolor{red}{+}$ src/components/shared/BandedDiv.tsx : BandSide
     src/utils/billing-utils.ts : FREE_GB_BY_TIER
     src/types/index.ts : InferredSchemas
     src/types/index.ts : Shard
     src/components/shared/WizardDialog/index.ts : WizardStep
     src/api/dataPlanes.ts : AwsDnsEntry
     src/stores/ShardDetail/types.ts : TaskShardDetailsWithShard
     src/stores/ShardDetail/types.ts : ShardDetails
     src/components/tables/Logs/types.ts : RefreshLogsFunction
     src/types/schemaModels.ts : CollectionSchema
…and 21 more

14 Unused exported enum members

An enum member referenced nowhere

     src/services/supabase.ts : CONNECTOR_TAGS
     src/services/supabase.ts : DRAFTS_EXT
     src/services/supabase.ts : TASKS_BY_DAY
     src/stores/Tables/hooks.ts : accessGrants
     src/stores/Tables/hooks.ts : accessLinks
     src/stores/Tables/hooks.ts : billing
     src/stores/Tables/hooks.ts : connectors
     src/stores/Tables/hooks.ts : entitySelector
     src/stores/Tables/hooks.ts : prefixes
     src/stores/Tables/hooks.ts : prefixAlerts
…and 4 more

5 Unused dependencies

In package.json but never imported

     package.json : @mui/lab
     package.json : @testing-library/jest-dom
     package.json : @urql/exchange-retry
     package.json : logrocket-react
     package.json : stripe

3 Unused devDependencies

In package.json devDependencies but never used

     package.json : @types/logrocket-react
     package.json : @types/react-inspector
     package.json : sharp

Base automatically changed from greg/dash/nav to main July 17, 2026 13:08
@GregorShear GregorShear mentioned this pull request Aug 19, 2026
@GregorShear GregorShear changed the title Service accounts and API keys Service accounts Aug 21, 2026
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.
@GregorShear
GregorShear changed the base branch from main to greg/banded-div August 28, 2026 02:18
@GregorShear
GregorShear force-pushed the greg/access branch 2 times, most recently from 5b4a232 to 8bd6084 Compare August 28, 2026 03:37
@GregorShear
GregorShear changed the base branch from greg/banded-div to greg/alert-box August 28, 2026 03:38
@GregorShear
GregorShear changed the base branch from greg/alert-box to main August 28, 2026 14:21
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.
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.

2 participants