Skip to content

Separate accounts from repositories, and give an account a role - #120

Merged
artyomsv merged 35 commits into
masterfrom
feat/accounts-and-roles
Sep 8, 2026
Merged

artyomsv merged 35 commits into
masterfrom
feat/accounts-and-roles

Conversation

@artyomsv

@artyomsv artyomsv commented Sep 7, 2026

Copy link
Copy Markdown
Owner

What

Separate who acts from where it acts in Settings, and add the one field the M2 factory needs and the UI cannot set: an account's role.

The screen labelled Repositories is the SCM account registry (one bot token per forge and workspace). The screen labelled Webhooks is the one that lists repositories. POST /api/runs tells an operator to register the machine account "under Settings -> Providers", a screen that does not exist in the nav. This PR renames the three screens, adds the role field, and shows on each repository row which account reviews it and which pushes.

Design: docs/superpowers/specs/2026-09-07-accounts-and-roles-design.md. Plan: docs/superpowers/plans/2026-09-07-accounts-and-roles.md.

today becomes
Repositories (/settings/providers) Accounts (/settings/accounts), tabs Machine accounts and People
Operators (/settings/operators) the People tab of Accounts
Webhooks (/settings/webhooks) Repositories (/settings/repositories)

No new tables. No migration. One new read-only endpoint. Old routes redirect and keep ?edit=.

Checklist

  • Design spec

  • Implementation plan (13 tasks)

  • T1 Backend: an account's role is fixed after registration; a changing PUT gets 409 (§6.2)

  • T2 Backend: GET /api/providers/serving — which accounts serve a (forge, workspace), five states (§6.1)

  • T3 Backend + gateway: every message and attention link names a screen that exists (§6.3)

  • T4 UI: ProviderView.role/botUsername, ProviderInput.role, ServingAccounts types; pure helpers (§6.4)

  • T5 UI: routes, redirects preserving the query string, nav rail, titles, bell labels (§7)

  • T6 UI: Accounts tab strip; Operators becomes the People tab (§5.1)

  • T7 UI: split the accounts screen into page, table and form (no behaviour change)

  • T8 UI: Machine accounts list — Kind, Role, Identity, Scope; tracker rows read-only (§5.1)

  • T9 UI: account form — Role field, fixed on edit; Factory hides reviewer-only fields; allowlist asks for the stable id (§5.1)

  • T10 UI: Repositories — Reviewed by / Pushed by chips with per-account Verify; picker offers reviewer accounts only (§5.2)

  • T11 UI: Context — Used by column (§5.3)

  • T12 Docs: README, ROADMAP, SMOKE-TEST, HISTORY (§10)

  • T13 Mutation checks: 5/5 killed by exactly the predicted tests (§9)

  • /code-review round 1 (four lenses, ref f5b6bab); 16 findings fixed in a8abcd8, 84d4f5f, bd0d998; re-review clean; dispositions in .claude/reviews/global/accounts-and-roles.md

  • Final verification (2026-09-08, every module re-run): Java 324 suites / 2889 tests / 0 failures / 1 pre-existing skip (testFast + testServices); spire-ui 70 files / 529 tests; tsc --noEmit silent

  • Post-test fixes from the operator's walkthrough: one line per cell, chips without Verify, URL truncation (34daaa8); one chained re-login with the route restored (cc89567); eight-column Accounts, one Accounts column and no Secret column on Repositories (b9d831a, d2dee80); reviewed clean

  • Accounts fits the page: long values truncate with a copy button and the full value on the tooltip, the kind is an icon (5e8b608); the connection is one four-state badge and a tracker is managed from an icon button (2916b62); the connection is an icon, Policy is two marks, values truncate tighter (d7d8f2e)

  • /code-review round 2 (four lenses, ref d7d8f2e, scope e9cff10..HEAD): no HIGH from any lens; Semgrep 16 files / 0 findings. 20 findings fixed in 2d0244d — a disabled account reporting "Not checked" over a stored rejection, three cells with no accessible name, an unknown conversation level reading as "Inherit", a width bound every browser ignores on a table cell, and a style contract that could not see a class written in a template literal (widening it caught a real orphan, prov-error). Dispositions in .claude/reviews/global/accounts-and-roles.md

  • Final verification (2026-09-08): spire-ui 71 files / 560 tests / 0 failures; tsc --noEmit silent; six mutation probes, each killed by exactly the predicted test

Out of scope (recorded in the spec, §11)

Tracker accounts with write rights; many accounts per workspace; push-rights verification; a "review this account's pull requests" toggle; moving the allowlist to a workspace policy.

The Settings screen labelled "Repositories" is the SCM account registry,
and the one labelled "Webhooks" is the screen that lists repositories.
The 409 from POST /api/runs sends an operator to "Settings -> Providers",
which is in no nav, to set a role the form cannot set.

The design renames the three screens (Accounts with Machine accounts
and People tabs; Repositories), adds a role field that is fixed after
registration, shows on every repository row which account reviews it
and which pushes, and adds one read-only endpoint that answers that
question with the same resolvers the pipeline uses. No new tables.
Out-of-scope items are listed with the reason each stays out.
Every settings screen consumes ?edit=<id> through useEditDeepLink, so
the Manage-on-Context link can carry it and the redirects must keep it.
Also lists the attention bell's label map and the one existing test
that asserts an editable role, which the design reverses.
Thirteen tasks: role fixed after registration, the serving-accounts
endpoint, screen-name strings, UI types, routes and redirects, the
Accounts tabs, a split of the accounts screen, the list, the form, the
Repositories chips, the Context column, docs, and mutation checks.
A PUT that names a different role is refused with 409. Changing a role
re-purposes one token under the other authority set: the review path
would post as the push identity, or the factory would push as the bot.
A role-less PUT keeps the stored role, as before; an equal one is a
no-op. Registering a new account and deleting the old one is the way
to change a role, and the refusal says so.
The status alone would stay green if the message moved off the
response body, which is the one thing the refusal exists to carry.
GET /api/providers/serving?type=&workspace= reports the reviewer and
the factory registration for one forge and workspace in one of five
states: ok, no-identity, no-login, disabled, missing. The factory
answer comes from MachineAccounts.resolve, the filter the dispatch
path applies, so the screen that shows it and the 409 that refuses a
run cannot disagree. Admin-only, like every registry read; built from
the view, so it can carry no secret.
A reviewer whose token named no login but did resolve an account id
is a recognisable bot. The existing rows set both fields or neither,
so a check on the wrong field passed every test.
Server messages and attention links named Settings -> Providers and
Settings -> Webhooks. The nav calls the first Repositories and the
dashboard is about to call it Accounts and the second Repositories.
Every string now points at the screen the operator will find.
ProviderView and ProviderInput gain role; ServingAccounts mirrors the
new endpoint. Two pure modules read them: roleLabel treats an unlisted
role as unknown rather than as a reviewer, and servingChip maps the
five states to the review pills' tones with an unlisted state grey,
never green.
One sentence said FACTORY and the next said Factory. The operator is
being sent to a form whose field reads Factory, so both now do.
The bot-token registry was labelled Repositories and the per-repository
webhook registry was labelled Webhooks. The first is now Accounts, with
Operators as its People tab; the second is Repositories. The three old
routes redirect and keep their query, because ?edit=<id> is what opens
the record an attention row names. The bell labels both old and new
paths so a row from a not-yet-upgraded service still reads correctly.
Machine accounts hold a token and a role; people are operators the
identity provider knows. They share a page, never a table. The strip is
plain anchors so a screen rendered without a router keeps working.
A mechanical extraction ahead of the changes the next two tasks make to
the table and the form. No behaviour changes; the existing tests pass
unchanged.
Forge accounts show Kind, Role, the login the forge knows them by and
the workspace they serve; reviewer-only columns are dashes on a Factory
row. Tracker and knowledge accounts appear read-only after them with a
link to Context, so one screen answers who acts as what while two
registries still back it.
The form gains a Role field: Reviewer by default, Factory for the
machine account the M2 factory needs and could not be registered from
the UI. On edit the stored role is shown read-only and sent back
unchanged. A Factory account has no allowlist and no conversation
level, so those fields are not offered for it. The allowlist asks for
the stable user id: /fix accepts ids only, and the field said username.
The Repositories screen asks the orchestrator which accounts serve each
row's forge and workspace, once per distinct pair, and renders both
roles as chips: green only when usable, amber for a registration that
exists but will not work, grey for disabled, missing or unknown. Each
chip verifies with its own account. The picker offers reviewer accounts
only and says that nothing about accounts is stored on the row.
Hiding a field and clearing it from the payload are different
things, and only the first was tested. The test now fills both
reviewer fields before choosing Factory and asserts they are gone.
The effect ran on every array identity, so each save or delete
re-fetched and flashed every chip to loading, and a caller passing
an inline array would loop. It now runs when the set of forge and
owner pairs changes. A chip whose account changed drops the verify
result it no longer describes.
Every source is read by one consumer today, the reviewer. The column
makes that visible beside the account that reads it.
The runbook, the README and the roadmap named screens the nav no longer
has. The smoke test's factory step registers the machine account from
the UI now that the form has a Role field.

The register dialog said Settings -> Providers too.
The row described the webhook registry by its old screen name.
Two screens still sent operators to Settings -> Operators, a nav entry
that no longer exists. A Verify whose account changed while the request
was in flight could write "reachable" beside a different account; the
request is now numbered and a stale answer is dropped. The Accounts page
showed its empty state for one render when only tracker accounts existed,
because loading was cleared before the second fetch. fetchServingAccounts
gains the URL-construction test every api function here has, with a
nested-group owner so the encoding is pinned.
The serving endpoint answered "can this factory account push" by
resolving the account, which decrypts the push credential on every
Repositories page load and turns an undecryptable ciphertext into a 500.
The one rule behind that resolution is that a login exists; it is now a
static predicate on MachineAccounts, applied to the view the endpoint
already holds, so dispatch and display still share one rule. The
endpoint also refuses an unknown forge type with 400 instead of a
confident "missing", and the two renamed attention actions are asserted.
.env.example, four runbook lines and two roadmap lines still named the
Providers and Webhooks screens. Two techdebt entries record the size
overage of the two Settings screens this branch grew and the constant
Used-by cell; the orchestrator size entry gains ProviderRegistry.
The review state file records what round 1 of the four-lens review
found and how each item was closed or dismissed. The status snapshot
names the Accounts and Repositories screens and carries the counts
measured after every module was re-run.
@artyomsv
artyomsv marked this pull request as ready for review September 7, 2026 22:36
When only the gateway's and the worker's sessions had lapsed, the
dashboard re-logged them in one prefix per page load, each a full
navigation back to the root: one blink per prefix, ending on Reviews.
The chained login the cold sign-in already uses serves this case too,
so the browser paints once. The route the operator was on is kept in
the browser across the jump and restored on return; the server still
never redirects to a client-supplied address.
Rows on Accounts were three lines tall because the connection cell
stacked the login, the date and the time, and the kind wrapped. The
Repositories table stacked a Verify button under each chip and let the
payload URL take the width. Cells no longer wrap, the last-check badge
shows the date and keeps the time in its tooltip, the URL truncates
with the full path on copy, and the chips stand alone: what they show
is which account serves the row, and verifying happens where the
account or the webhook is edited.
The Manage-on-Context link wrapped onto three lines in a squeezed last
column, which is what made the tracker rows tall. Long identities and
check failures now truncate with the full text on hover. Accounts
drops to eight columns: enabled is a dot by the name, and the two
reviewer-only settings share one Policy cell. Repositories drops the
Secret column, which always read "secret set", and shows both serving
accounts in one labelled cell.
The note said where the secret comes from and forgot to say what to
do with it. A webhook wired with the URL alone fails signature checks.
The table outgrew the page. Three cells held values with no bound on
their length — the base URL, the bot identity and the workspace — and a
fourth repeated the same category word on every row.

Long values are now truncated to the column and carry the whole value in
their tooltip and on a copy button, which is what an operator does with
them: paste them into a forge's own portal. The kind is an icon with the
word as its accessible name and its tooltip, so Forge, Tracker and
Knowledge stop spending a column on three repeated strings. A rejection
message from a provider is held to one line at a tighter width.
The Accounts table still ran off the page. Two cells were the reason.

The connection cell printed the login the token authenticated as, the
date of the last check, and on a refusal the provider's own message —
three variable-length strings, the longest of them a paragraph. It is
now one badge in four states: OK, Failed, Not checked, Checking. All
three strings moved to its tooltip, which is where an operator reads
them once rather than down a column of eleven rows. A tracker's badge
reports the stored standing and is not a control: nothing is checked
from this screen, that happens on Context.

The tracker row's "Manage on Context" was a button of words two icons
wide, and squeezed it wrapped, which is what made those rows taller than
the forge rows above them. It is the shared icon button now, lining up
with Edit and Delete, with its name on the hover and as its label.
Three cells still spent width on words. The connection badge carried its
state as a label beside a dot, the Policy cell was a sentence — "0 ids ·
Inherit (global)" — and the two copyable values were bounded a quarter
wider than they need to be.

The connection is one icon in four shapes and colours: a tick, a cross,
a dashed circle, a spinner. The word is the icon's accessible name and
the head of its tooltip, so nothing is lost to a screen reader or to a
hover, and a column of eleven rows is read by shape. Policy is a
head-count icon with a number and the conversation level without the
parenthesis the form needs, each with its own sentence on hover.
Identity and Scope truncate at 150px instead of 200.
The walkthrough fix wave moved information out of visible text and into
icons, tooltips and copy buttons three times over. Review found where
that lost something.

A disabled account reported "Not checked" while the registry held a
rejected token, because the badge hard-coded idle for anything not
enabled; both badges now derive the stored standing the same way, and a
live result no longer outlives the account that earned it. The enabled
dot and the two policy marks named a bare span, which the generic ARIA
role cannot carry, so they had no accessible name at all. The connection
button was named after its state and never its action. An unknown
conversation level read as "Inherit" because the short form compared a
rendered label instead of the level. The payload URL's width bound sat
on a table cell, where every browser ignores it.

The style contract now reads a class out of a template literal and
excuses nothing by prefix, which caught prov-error: asked for twice by
the General screen and defined nowhere since it was written. Five
behaviours no test reached have tests, the accounts table's cells moved
into their own file under the size cap, and the return-route guard
refuses a protocol-relative hash itself rather than trusting the router.
@artyomsv
artyomsv merged commit df771ad into master Sep 8, 2026
14 checks passed
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.

1 participant