Skip to content

feat(accounts): manage service accounts from account settings - #567

Merged
alukach merged 19 commits into
mainfrom
feat/service-account-management
Sep 24, 2026
Merged

alukach merged 19 commits into
mainfrom
feat/service-account-management

Conversation

@alukach

@alukach alukach commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Closes #547. Part of #491. Stacked on #566, now merged, so the diff is this branch alone: the feature commit, the immutable-subject commit, two review-fix commits, a redesign of the form and the list, a fix for writing trusts, owner-namespaced ids, and an account page that is where creating lands and where grants are edited, with the create form's product list, showing what it reaches and granting more from a new row.

What

The real version of the flow #495 mocked, built on the model (#563), the membership pages (#564) and account trusts (#566). Rebuilt on the trust model in one commit; what review changed follows as commits of its own.

Settings → Service Accounts appears for individuals and organizations alike, to whoever canManageAccount says manages the account. The list page loads each service account with its trusts, its grants and (from #570) its keys.

ServiceAccountForm — who it is (the name, and the id derived from it, shown as code with an Edit link the way the data-connection form shows its id), how software signs in (any number of GitHub workflows, each a card with the repository on its own row — owner/repo, or owner@id/repo@id for the immutable subjects GitHub mints for repositories created after July 2026 or opted in — then a Ref/Environment segmented control and its value, and the exact subject shown as you type), what it may reach (the products it reaches, each with Read / Read and write and a red X, and Grant a product, which adds a row with a dropdown of the owner's other products (each option the product's name over its owner/product path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same ProductAccessList on the create form and the account's page; each title opens the product in a new tab, and the form holds its grants until it is submitted). The id is namespaced under the owner: {owner}--{id}, such as acme--nightly-sync, joined by the -- account-owned data connections already use. Two owners can each have a nightly-sync, a service account never takes a handle a person or organization might want (their ids cannot contain --), and no service-account id can equal an Ory identity id, which is a UUID. The form asks for the short id and shows the whole one; the action composes it. ServiceAccountSchema requires the composed form, and a membership's member id accepts it, which granting a namespaced service account a product needs. The general createAccount action no longer takes type=service: nothing posted it, and it created service accounts without the owner checks or the namespace. Submitting writes the account, its grants and its trusts, and redirects to the account's page, where each trusted workflow's example usage is a click away. Nothing has to run first, and the step carries nothing that expires.

ServiceAccountList — one row per account in the data connections' own ConnectionList/ConnectionRow: the name linking to the account's page, its id, a marker when it is disabled, and how many workflows it trusts and products it reaches.

ServiceAccountDetail, at /edit/account/{owner}/service-accounts/{id} — the account's page: the workflows it trusts as rows, each removed with a red X (captioned "Remove" on hover, labelled with the subject for screen readers), each with an Example usage link that opens the step the workflow adds in a modal, and the "Trust a GitHub workflow" dialog on the section header, which writes the trust and closes onto the new row; the products it reaches, each with Read / Read and write and a red X, and Grant a product, which adds a row with a dropdown of the owner's other products (each option the product's name over its owner/product path in small monospace), a Read / Read and write choice, and a green check that finalizes it, beside an X that cancels — the same ProductAccessList on the create form and the account's page, here saved as each is made (the check, a change of access, or an X), shown at once (useOptimistic) with the controls held while it saves; each title opens the product in a new tab so a viewer can check what it holds; and Disable and Delete set apart in a danger zone. The page resolves the account through managedServiceAccount and 404s unless it is under its own owner. The actions revalidate the list and the page. Delete confirms in an alert dialog whose submit runs the action (a plain button, not AlertDialog.Action, which would close the dialog before the action ran) and shows the action's message if it refuses; deleting removes the grants and the account with its trusts, then redirects to the list. The trust dialog's form lives inside Dialog.Content, so each open starts fresh. The page passes the data-proxy origin down for the example; without one, no example is offered.

The workflow snippet (src/lib/services/github-workflow.ts) is the aws-actions/configure-aws-credentials@v6 step pointed at the data proxy — sts-endpoint, audience = the proxy origin — naming the account in role-to-assume: arn:aws:iam::<service-account-id>:role/FullAccess, plus env pointing S3 clients at the proxy. From then on any S3 client in the job works, if the account trusts the workflow's subject. Depends on developmentseed/multistore#126: the action validates the credentials it exports with GetCallerIdentity, which the proxy answers once that lands.

AccountInfoHoverCard, the card an account name opens on hover anywhere in the app, marks a service account with the outlined "Service account" badge the picker and memberships table already use.

What this PR adds to the model: canManageServiceAccount(session, account, owner) — whoever manages the owner manages the service account, answered through canManageAccount on the fetched owner, so an admin is included, a disabled owner takes its service accounts out of reach with it (admins aside), and nothing that is not a service account qualifies; a disabled service account is still managed so it can be re-enabled or deleted. managedServiceAccount(session, id) in src/lib/accounts/service-accounts.ts is the one resolver the lifecycle actions (and, in #570, the key actions) go through: it loads the account and its owner and answers null for anything not managed. And AccountTrustsTable.create (schema-validated, conditional, AlreadyTrustedError) and delete, which were in #566 until review noted nothing there called them.

Server actions (src/lib/actions/service-accounts.ts): createServiceAccount, addGithubTrust, removeTrust, setServiceAccountDisabled, deleteServiceAccount, and for grants setProductAccess. Create loads the owner and requires an enabled individual or organization the caller manages — settled before any of its products are read, so the form can't be used to probe another account's catalogue — deduplicates the workflows and grants it was posted. The rest go through managedServiceAccount; setProductAccess also applies serviceAccountGrantProblem (read or write, the owner's products only), changes the grant the account already has rather than adding a second, touches only the account's own grants, and on none revokes by setting the membership Revoked, as a person's is. No action builds a workflow step: the page does, from the proxy origin. A disabled account takes no new trust, and a repeat is named for what it is. removeTrust deletes by the account's own key, so it can never touch another account's.

Stories

On this branch's deploy:

The form, the detail and the dialog use useActionState, so they are on the smoke test's cannot-render-under-jest list alongside the other action-state forms; WorkflowSnippet, GithubWorkflowFields and ProductAccessList render there. The stories and the Storybook mock build the step with githubWorkflowStep rather than carrying a copy of it.

Screenshots

The create form: the id derived from the name, one workflow card, and each product's access chosen in place:

The New Service Account form: Name "Nightly Sync" with the account id nightly-sync shown beneath it and an Edit link; a workflow card with Repository miskatonic/climate-data, a note on the immutable owner@123/repo@456 form, a trash button, a Ref / Environment segmented control on Ref with refs/heads/main, and the text Trusts repo:miskatonic/climate-data:ref:refs/heads/main; then Climate Data set to Read and write and Reference Data set to Read

The list, one row per account:

Two rows in a bordered list: Nightly Sync, nightly-sync, 1 workflow · 1 product; Archive Mirror, archive-mirror, cannot sign in · 0 products; each with a chevron

The id, derived from the name under the owner, and the same id opened for editing:

Account ID: miskatonic--nightly-upload, made from the name Nightly Upload, with an Edit link

The Account ID field open for editing: the prefix miskatonic-- set flush against the editable nightly-upload, in one monospace face

An account's page, where creating one lands. Each trusted workflow has Example usage; the products it reaches are listed with their access and an X, with Grant a product below:

Nightly Sync, miskatonic--nightly-sync; Signs in as, two workflows each with Example usage and a red X; Can reach, Climate Data set to Read and write and Reference Data set to Read, each with a red X, then a Grant a product button; Danger zone, with Disable and Delete

After Grant a product: a row to choose the product and its access, finalized with the check:

Below the two granted products, a row with Field Notes chosen in a dropdown, Read and write selected, a green check and a grey X

A trust row, with the Remove caption showing on the X:

A trust row for repo:miskatonic/climate-data:ref:refs/heads/main with Example usage and a red X button, hovered, captioned Remove

The grant row's dropdown, each product's name over its path:

The Choose a product dropdown open, listing Climate Data over miskatonic/climate-data, Reference Data over miskatonic/reference-data, and Field Notes over miskatonic/field-notes

The create form uses the same list and grant row, and holds its grants until it is submitted.

Example usage, opened from a workflow's row:

A modal titled Sign in from this workflow: add to the job in repo:miskatonic/climate-data:ref:refs/heads/main the configure-aws-credentials step whose role-to-assume is arn:aws:iam::miskatonic--nightly-sync:role/FullAccess, with a copy button and Close

WorkflowSnippet on its own, with a subject in the immutable form pinned to an environment — the block scrolls sideways rather than wrapping the YAML:

Add to the job in repo:miskatonic@8123456/climate-data@9456789:environment:production, before it uses the data, followed by the configure-aws-credentials step for archive-mirror and a copy button

GithubWorkflowFields as the create form stacks it, an immutable repository name pinned to an environment, with the Remove button:

Repository miskatonic@8123456/climate-data@9456789, Pinned to An environment, Environment production, a Remove button, and the text Trusts repo:miskatonic@8123456/climate-data@9456789:environment:production with the note on naming the repository the way its tokens do

A service account's hover card:

A hover card for Nightly Sync, @miskatonic--nightly-sync, with an outlined Service account badge

Testing

  • npx jest — all suites pass, re-run after the redesign. service-accounts.test.ts: create writes the account, grants and trusts once each however many times a field was posted, and redirects to the account's page; refuses an unpinned workflow, a product the owner lacks and a bad role before writing; refuses a non-manager, a missing owner and a service-account owner, and reports a taken id on the short-id field; delete removes grants then the account and redirects to the list; a short id containing its own -- is refused; disable/enable; remove a trust by the account's key; trust one more workflow, refusing an unpinned subject, a repeat and a disabled account; every lifecycle action refuses what managedServiceAccount does not hand back. accounts/service-accounts.test.ts: the resolver hands back the account only when it is a service account whose owner is found and managed. github-workflow.test.ts: the step names the account and the proxy and carries nothing that expires.
  • authz.test.ts: the manager rules — admin-over-organization refused, some other account offered as the owner refused, a disabled owner refused for its managers and allowed for an admin. account-trusts.test.ts: write keyed by account and identity, only once; AlreadyTrustedError; malformed rows refused before the table; delete by the same key.
  • service-accounts.test.ts, setProductAccess: a product the account does not reach is granted as a member of the owner; one it reaches has its grant changed rather than a second added, and none revokes it (and is a no-op where there is no grant); a product the owner lacks, a role beyond read or write, and a non-manager are refused before any write; only the account's own grants are read.
  • account-trusts.test.ts also asserts the create condition names identity by placeholder. Unit tests mock DynamoDB, so they could not catch the reserved word; it surfaced when a trust write failed on a deployment with "Attribute name is a reserved keyword; reserved keyword: identity".
  • npm run type-check, next lint, npm run build-storybook — clean.

Docs and ADRs

data.source.coop: ADR-014 (source-cooperative/data.source.coop#232) describes this flow as amended with #566, and is amended again for the namespaced id (commit ae85ff72 on that PR), which replaces its "no reserved id namespace". docs.source.coop: the unattended-workflow guide (source-cooperative/docs.source.coop#34) will carry the workflow step this issues; it should read the way the AWS one does.

🤖 Generated with Claude Code

https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z

@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
source-coop-ui Ready Ready Preview Sep 24, 2026 3:16pm UTC
source-cooperative Ready Ready Preview Sep 24, 2026 3:16pm UTC

Request Review

@alukach alukach added the blocked Waits on another PR merging first label Sep 21, 2026
@claude

claude Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 2m 23s —— View job


Reviewing PR #567

  • Gather context (PR description, diff, changed files)
  • Review diff for correctness/security/best-practice issues
  • Apply ponytail-review pass for over-engineering
  • Check docs/ADR references in description
  • Post final review

✅ No blocking issues — safe to merge.

Read through the full diff against origin/main: the actions in src/lib/actions/service-accounts.ts, the managedServiceAccount/canManageServiceAccount authorization funnel, the namespaced-id schema changes (SERVICE_ACCOUNT_ID_REGEX, serviceAccountId), the account-trusts table (including the reserved-word fix), the GitHub subject regex and workflow-snippet builder, and the UI components (ServiceAccountForm, ServiceAccountDetail, ProductAccessList, GithubWorkflowFields).

A few things I specifically checked and didn't find problems with:

  • createServiceAccount (src/lib/actions/service-accounts.ts:65) settles the owner and canManageAccount before reading any of the owner's products, so it can't be used to probe another account's catalogue — matches the PR description.
  • managedServiceAccount (src/lib/accounts/service-accounts.ts:44) is the single funnel every lifecycle action goes through, and correctly returns null for a missing account, a non-service account, or an owner that's gone/disabled (admins excepted, verified in authz.test.ts).
  • removeTrust (src/lib/actions/service-accounts.ts:191) and deleteServiceAccount (:225) key strictly off the resolved account's own account_id, so neither can touch another account's rows; accountsTable.delete (pre-existing, unchanged here) transactionally removes the trusts alongside the account row.
  • The {owner}--{id} namespacing is consistent end-to-end: ID_REGEX still rejects -- for person/org ids, SERVICE_ACCOUNT_ID_REGEX requires it, and ACCOUNT_ID_REGEX (used for Membership.account_id) accepts either form.
  • GITHUB_ACTIONS_SUBJECT_REGEX correctly rejects a mixed mutable/immutable repository name (e.g. owner@123/repo, no id on the second half) since @ isn't in either alternative's mutable character class.
  • setProductAccess (:257) revalidates role/ownership via serviceAccountGrantProblem before writing, and changes an existing grant in place rather than creating a duplicate membership.
  • The general createAccount action (src/lib/actions/account.ts) no longer accepts type=service, closing the path that previously created service accounts without owner checks or namespacing.

Simplify (ponytail)

  • src/lib/actions/service-accounts.ts:248 — managedFrom wraps managedServiceAccount(await getPageSession(), ...) and is called from exactly one place (setProductAccess). Inline it there; the indirection doesn't pay for itself at one call site.

Docs

The description names both docs this flow touches and either points at the amending PR or explains why it still holds: ADR-014 in data.source.coop (source-cooperative/data.source.coop#232) is called out as amended for the trust model and again for the namespaced id, and the docs.source.coop unattended-workflow guide (source-cooperative/docs.source.coop#34) is named as the doc that will carry this workflow step. Nothing in the diff looks like it moves a decision beyond what's already flagged there.


💰 Estimated review cost: $1.15 · 2m23s · 39 turns

@alukach

alukach commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Addressed. createServiceAccount now authorizes before any read on the owner the caller named, with a test asserting a non-manager triggers no product lookup. Also took both simplifications: GithubWorkflowFields (with githubSubject) is shared by the form and the dialog, and the idle states are defined once in @/types.

@alukach
alukach added this pull request to stack #571 September 23, 2026 04:30
@alukach
alukach force-pushed the feat/service-account-management branch from a966b4a to 0746eaa Compare September 23, 2026 04:37
@alukach
alukach force-pushed the feat/service-account-management branch from 0746eaa to 677e637 Compare September 23, 2026 06:00
@alukach
alukach force-pushed the feat/service-account-management branch from 677e637 to 84cb5dc Compare September 23, 2026 06:33
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit: the subject pin accepts GitHub's immutable subject form (repo:owner@123456/repo@456789:…, per https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/) alongside the mutable one — both names carry ids or neither — and the form tells the user to name the repository the way its tokens do. Without this, no repository created after July 2026 could be trusted. Description updated.

alukach and others added 2 commits September 23, 2026 18:23
Creating a service account now loads the owner and requires an enabled individual or organization the caller manages, so an admin can no longer create one under a nonexistent, disabled or service account — the settings layout offers the tab on a service account's own page, and nothing before this refused it. canManageServiceAccount takes the fetched owner and answers through canManageAccount, so a disabled owner takes its service accounts out of reach the way it takes the owner itself, admins aside; managedServiceAccount in lib/accounts is the one resolver every lifecycle action goes through, and each action resolves the session once instead of twice. The create form's subjects and grant fields are deduplicated before anything is written, so a workflow card or grant left in twice is one trust and one membership rather than an uncaught AlreadyTrustedError after the account exists, and a missing data-proxy endpoint fails the trust before it is written instead of handing out a step with empty endpoints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… parts their stories

The Delete confirmation's submit sat inside AlertDialog.Action, which closes the dialog on click and unmounts the form before React dispatches the server action, so deleteServiceAccount never ran; it is a plain submit now, as DirectoryRow's is, the dialog leaves with the card once the account is gone, and stays to show the action's message when it refuses. The trust dialog keeps its form and result inside Dialog.Content, which Radix unmounts on close, so reopening it offers a fresh form rather than the previous snippet. WorkflowSnippet and GithubWorkflowFields get stories of their own, and the stories and the Storybook mock build the workflow step with githubWorkflowStep instead of carrying a copy of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Two review-fix commits, on top of the branch rather than amended into it, and #570 rebased onto them.

2ffba12 — settle a service account's owner before acting on it. Create loads the owner and requires an enabled individual or organization the caller manages, so an admin can no longer create one under a nonexistent, disabled or service account. canManageServiceAccount takes the fetched owner and answers through canManageAccount, so a disabled owner takes its service accounts out of reach (admins aside); managedServiceAccount in lib/accounts is the one resolver, and each action resolves the session once. Subjects and grant fields are deduplicated before anything is written, and a missing data-proxy endpoint fails the trust before it is written.

cfc05e3 — run the delete, restart the trust dialog, and give the parts their stories. The Delete submit was inside AlertDialog.Action, which closed the dialog before the action was dispatched, so it never ran; it is a plain submit now, and the dialog shows the action's message if it refuses. The trust dialog's form lives inside Dialog.Content, so reopening it offers a fresh form. WorkflowSnippet and GithubWorkflowFields have stories, and the stories and the mock build the step with githubWorkflowStep.

Description updated to match; stories and screenshots added.

…s own

The service-accounts list is one bordered list of rows, the data connections' own ConnectionList and ConnectionRow: the name linking to the account's page, its id, a marker when it is disabled, and how many workflows it trusts and products it reaches. A card per account with every control on it read as a wall of badges and did not scale past a handful. Each account's page, /edit/account/{owner}/service-accounts/{id}, renders ServiceAccountDetail: the workflows it trusts as rows with Remove and the trust dialog on the section header, the products it reaches, and Disable and Delete set apart in a danger zone. Deleting redirects to the list, and the actions revalidate both pages. The id "create" is refused, since it is the create page's own path segment.

The create form drops to one line of description per section. The account id is shown as it is derived from the name, with an Edit link, the way the data-connection form shows its id. A workflow card puts the repository on its own row, with a one-line note on the immutable form, and the ref or environment on the next, chosen with a segmented control. Each product is a row with a None, Read, or Read and write control in place of a checkbox that revealed a select. The created view links to the new account's page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit ba495d6 redesigns the form and the list, following the data-connection pages.

  • The list is one row per account in the data connections' ConnectionRow: the name, the id, a marker when it is disabled, and how many workflows and products it has. Every control moves to the account's own page, /edit/account/{owner}/service-accounts/{id}, which renders the new ServiceAccountDetail: its trusts, its grants, and Disable and Delete in a danger zone. Delete redirects to the list.
  • The form has one line of description per section. It shows the derived id as code with an Edit link, puts the repository on its own row with a Ref/Environment segmented control below it, and gives each product a None / Read / Read and write control in place of a checkbox that revealed a select.

Stories, screenshots and the description are updated. #570 is rebased, and its API-keys section now lives on the account's page.

alukach and others added 3 commits September 23, 2026 20:46
Writing a trust failed with "Attribute name is a reserved keyword; reserved keyword: identity": DynamoDB reserves `identity`, and the create condition named the sort key bare. The condition now names it through ExpressionAttributeNames. No other expression on the table names it; the reads address it through Key, which reserved words do not affect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… link each product it reaches

A service account's id is now its owner's id and its own short id joined by `--`, the delimiter account-owned data connections already use: acme--nightly-sync. Two owners can each have a nightly-sync, a service account never takes a handle a person or organization might want (their ids cannot contain `--`), and no service account id can equal an Ory identity id. The form asks for the short id, derived from the name as before, and shows the whole id; the action composes it, so the id "create" no longer needs refusing. ServiceAccountSchema requires the composed form, and a membership's member id accepts it, which granting a namespaced service account a product needs. The general createAccount action no longer takes type=service: nothing posted it, and it created service accounts without the owner checks or the namespace. Fixtures and stories use the new ids.

On an account's page, each product under "Can reach" links to the product itself in a new tab, to check what it holds, with Manage beside it for the grant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…'s id

The edit field showed "miskatonic--  nightly-upload": the slot's own padding left a gap after the prefix, and the prefix and the input were in different faces. Both are in the code face now, with no gap, so the field reads as the one id it composes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commits:

  • 885bcf4 fixes writing a trust. DynamoDB reserves identity, and the create condition named the sort key bare, so every trust write failed with "Attribute name is a reserved keyword". It is named by placeholder now.
  • d34fa69 namespaces a service account's id under its owner, {owner}--{id}, and links each product under "Can reach" to the product itself in a new tab. It also stops the general createAccount action from creating service accounts.
  • 2c142cc fixes a trust's OpenAPI example id, and the latest commit sets the owner prefix flush against the editable id.

ADR-014 is amended to match on source-cooperative/data.source.coop#232. Description, stories and screenshots are updated.

…rkflow and grants edited in place

Creating a service account now redirects to its page instead of showing a post-create view, so ServiceAccountCreated is gone. On the page, each trusted GitHub workflow has an "Example usage" link that opens the step it adds in a modal, built from the page's data-proxy origin; the trust dialog closes onto the new row rather than showing the step itself, so the actions no longer build steps or read the proxy's address.

"Can reach" is edited in place: each product the account reaches has a Read / Read and write control and Remove, and a form grants another of the owner's products. Three actions back it — grantProduct, setGrantRole, revokeGrant — each resolving the account through managedServiceAccount, applying serviceAccountGrantProblem, and touching only the account's own live grants; revoking sets the membership Revoked, as a person's is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit b1e11f1:

  • Creating lands on the account's page. The post-create view, ServiceAccountCreated, is gone.
  • Example usage per workflow. Each trusted workflow has an "Example usage" link that opens its step in a modal. The trust dialog closes onto the new row instead of showing the step.
  • Grants edited in place. "Can reach" has a Read / Read and write control and Remove per product, and a form to grant another of the owner's products. It is backed by three new actions: grantProduct, setGrantRole and revokeGrant.

#570 is rebased, with its API keys section alongside. Description, stories and screenshots are updated.

…rvice account

The account's page granted products with a product dropdown, a role dropdown and a Grant button, and listed what it already had separately, with its own controls and "No products yet." when the owner had products but none were granted. It now uses the create form's list: every product the owner has, each with None / Read / Read and write, the title opening the product in a new tab. ProductAccessList is that list, shared by both; the form holds the choices until it is submitted, and the page saves each as it is made, showing it at once with useOptimistic and holding the controls while it saves.

One action, setProductAccess, replaces grantProduct, setGrantRole and revokeGrant: none revokes the account's grant on the product, as a person's membership is revoked; read or write changes the grant it has or grants the product, under the same checks as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit ce115aa makes the account's page use the create form's product list, ProductAccessList, in place of the product dropdown, role dropdown and Grant button. Every product the owner has gets None / Read / Read and write, saved as it is changed and shown at once. That also removes the "No products yet." that appeared when the owner had products but none were granted. One action, setProductAccess, replaces grantProduct, setGrantRole and revokeGrant. #570 is rebased; description, stories and screenshots are updated.

alukach and others added 2 commits September 23, 2026 21:36
The "Remove" text beside "Example usage" read as a second link and sat above it. Each trust row now ends in a red X icon button, labelled with the subject it removes and captioned "Remove" on hover; its form is a flex box, so it centres on the row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…rom a new row

The account's page listed every product the owner has with None / Read / Read and write, which is a lot of controls for editing what is already there. It now lists only the products the account reaches, each with Read / Read and write and a red X to remove it. "Grant a product" on the section header adds a row with a dropdown of the owner's other products; picking one grants it to read, saved at once like every change on the page, and the row becomes an ordinary one. The create form keeps the full list, since there every product is a choice to make.

ProductAccessList takes onRemove for the page's shape (no None, an X per row), an empty message, and extra rows as children for the draft.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit: the account page's "Can reach" lists only the products the account reaches, each with Read / Read and write and a red X. Grant a product on the section header adds a row with a dropdown of the owner's other products; picking one grants it to read, saved at once. The create form keeps the full list. #570 is rebased; description, stories and screenshots are updated.

…n the form and the page alike

Granting on the account's page chose only the product, and granted it to read the moment it was picked; the create form still listed every product with None / Read / Read and write. Both now use one ProductAccessList: the products the account reaches, each with Read / Read and write and a red X, and "Grant a product", which adds a row with the owner's other products, a Read / Read and write choice, and a Grant button that finalizes it (Cancel drops the row). The form holds its grants until "Create service account"; the page saves each when Grant, a change of access, or an X is pressed. The Grant button is highContrast, as FormActions' submit is, since a solid button in the theme's grey accent fails contrast for its label.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
@alukach

alukach commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

New commit b24adc7: granting a product now picks the product and its access (Read or Read and write) in one row, finalized with a Grant button, and it is the same component on the create form and the account's page. The form holds the grants until "Create service account"; the page saves each on Grant. #570 is rebased; description, stories and screenshots are updated.

alukach and others added 5 commits September 23, 2026 22:06
The grant row ends in a green check, captioned and labelled "Grant", beside the X that cancels it, in place of a "Grant" text button. It stays disabled until a product is chosen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
…he grant dropdown

Each option in the grant row's product dropdown now shows the product's name and, beneath it, its owner/product path in small monospace, so products with similar names can be told apart. The option grows past the one-line height Radix gives it, and the path dims the option's own colour rather than taking grey, which would vanish on the highlighted option.

The closed dropdown shows only the chosen name, through the trigger's children. They are an empty string, never undefined, before a choice: Radix copies the chosen option into a trigger with no children, and switching between that copy and the children failed with "Failed to execute 'removeChild' on 'Node'".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
The card that introduces an account on hover now marks a service account with the outlined "Service account" badge the account picker and the memberships table already give it, and says which account owns it, since a service account acts for its owner. People and organizations are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
… alone

"owned by @owner" beside the badge was more than the card needs; the badge says what the account is, and the card stays the short introduction it is for everyone else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z
`export * from "./service-account"` appeared twice in the types barrel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REZWKgQy2PDETn6j9YpM4z

This branch was successfully deployed

2 active deployments
Preview – source-cooperative — 1bc3adb2 Deployed Sep 24, 2026 by vercel[bot]
Preview – source-coop-ui — 1bc3adb2 Deployed Sep 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocked Waits on another PR merging first feat

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Management API and UI for Service Accounts

1 participant