Skip to content

feat: owner-controls UX — confirmations, shared share state, manual radiogroup (D1b, stacked on #57) - #59

Merged
datj9 merged 11 commits into
mainfrom
feat/owner-controls-ux
Sep 24, 2026
Merged

datj9 merged 11 commits into
mainfrom
feat/owner-controls-ux

Conversation

@datj9

@datj9 datj9 commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Plan item D1b feat/owner-controls-ux.

Stacked on #57 (refactor/ui-primitives-and-deps). It reuses that PR's ConfirmDialog, dialog.module.css and cx. Once #57 merges, retarget this PR to main.

Changes, by plan item

1. Confirmations via ConfirmDialog

  • Share revoke (app/a/[id]/share-dialog.tsx): a ConfirmDialog nested inside the Share dialog, so Esc closes only the confirmation. Test ids: share-revoke-dialog, share-revoke-confirm.
  • Token revoke (app/settings/tokens/token-manager.tsx), invite revoke (app/admin/invites/invite-manager.tsx), provider-key remove (app/settings/keys/key-manager.tsx).
  • User role / deactivate (app/admin/users/user-table.tsx): Deactivate, Make admin, Make member and Delete all go through one table-level ConfirmDialog. Reactivate still commits on the press, because it only restores access. The wording and request bodies live in user-actions.ts, which has unit tests. The deactivate confirmation says how many shared artifacts stay readable.
  • Every revoke/remove/deactivate confirmation keeps the dialog open on failure and shows the error inside it. On success it closes.

2. user-table.tsx error handling: requests now catch network failures. A refusal, such as the server's "still owns artifacts" message, stays inside the open dialog. It used to close the dialog and show the error behind the popup.

3. Privacy radiogroup (privacy-switch.tsx + radio-keys.ts)

  • Manual activation per the APG radio group pattern. Arrow keys, Home and End move focus only. Space or Enter commits through the button's native click. Walking from Only me to Organization with the keyboard no longer fires a PATCH, or the publish confirmation, for each option on the way.
  • After a successful PATCH, a polite role="status" region says "Saved" and clears after 2.5 s. It is always mounted and sits on the hint's line, so an empty region takes no space and saving doesn't shift the layout.

4. Owner-controls provider (owner-controls.tsx + pure owner-controls-state.ts)

  • page.tsx seeds one provider with the share list and liveCount. The Share dialog, the privacy switch and the Delete confirmation all read from it.
  • The switch and Delete no longer fetch /shares every time they open. The only list read left is the Share dialog's own re-read after a create or revoke.
  • The reducer drops list answers that arrive out of order, using a request id. A revoke the server has confirmed is marked immediately. liveCount is only ever taken from the server, because expiry is judged by Postgres now().
  • page.tsx runs the handoff signing and both owner queries in one Promise.all.

5. Busy labels and iframe title

  • Busy labels: Create link, Revoke link, Delete, Create token, Revoke token, Save/Update key, Remove key, Create invite, Revoke invite, Create category, Rename, Activate/Deactivate, Apply filters, Load more, Reactivate, and the user-action confirms. The busy button stays aria-disabled, not disabled, so it keeps focus.
  • The artifact iframe title is now the artifact's title. ArtifactFrame defaults to "Artifact" when no title is passed, so /s/[token], which this PR does not own, is unchanged. The iframe also gets data-testid="artifact-frame".

6. next/link: admin nav, settings nav, both "Back to artifacts" links, and the artifact page's "← Artifacts" link. The download links in download-menu.tsx stay plain <a> because they hit route handlers that return files.

Extra items

  • Provider-key errors (app/settings/keys/failure-message.ts): when a save or remove fails, the key manager shows error.message from the response envelope, for example fix: trusted-proxy client IP, sign-in hardening, SSRF policy for base URLs, OIDC invite verification #56's 422 VALIDATION_FAILED for a base URL that targets a refused address. It falls back to the generic line if the body isn't JSON, isn't an envelope, or has an empty or implausibly long message (over 300 characters). Unit-tested.
  • Docs: design.md § Exports no longer mentions Tailwind; it describes tokens consumed as CSS custom properties from globals.css and CSS modules. The Sonner toast row in docs/motion.md is replaced with the in-place status-message rule.

Accessibility notes

  • Focus after a confirmation closes. On cancel, focus goes back to the button that opened the dialog (base-ui default). On a successful revoke, that button is gone. The row is marked revoked locally before the dialog closes, and focus goes to the row, which has tabIndex={-1} and is not in the tab order. For key removal, focus goes to the stored-key status region. Without this, focus would fall to <body>.
  • Initial focus. Destructive confirmations keep the ConfirmDialog default, focus on the confirm button, to match the existing Delete dialog. The publish and private-downgrade confirmations still start on Cancel.
  • Stable wording. Dialog content is kept in state after close, so the text doesn't change during the 220 ms exit transition.
  • Screen-reader separator. The "·" before "Saved" is CSS content with empty alt text (content: '·' / ''), so it isn't announced.

E2E specs changed

Not run locally (no Docker). Changes are kept minimal:

  • share-link.spec.ts: revoke now clicks share-revoke, then share-revoke-confirm. The "pressing twice" test now checks that the busy confirm button keeps focus and that only one DELETE is sent, plus that opening the confirmation sends nothing.
  • share-count-expiry.spec.ts: extra confirm click; comment updated.
  • visibility-private-share-warning.spec.ts: extra confirm click. It now waits for the Share badge to drop its count before pressing Only me. The switch reads the shared state that the post-revoke re-read fills, and it no longer fetches its own count.
  • The iframe selector changed from iframe[title="Artifact"] to iframe[data-testid="artifact-frame"] in share-link, trash-delete-restore, two-account-privacy, viewer-sandbox, visibility-public-seo and zz-direct-artifact-entry.
  • Checked and unaffected: users-and-invites, which uses no UI revoke or deactivate; tokens-api-push, which revokes through request.delete; signin-change-password, whose nav link keeps its role and name.

Tests

  • pnpm typecheck: pass
  • pnpm lint: pass
  • pnpm vitest run --project unit: 77 files, 1222 tests, all passing. New files: privacy-radio-keys, owner-controls-state, admin-user-actions, provider-key-failure-message.
  • pnpm build with the .env.example values: pass
  • Playwright was not run locally.

Risks

  • A stale count is possible. The count no longer refreshes on open, so a link created or revoked from another tab or the CLI while the page is open isn't reflected until reload. The same goes for a link that expires while the page is open. In every case the error overcounts, so the UI warns about a link that is gone rather than staying silent about one that still opens. This is documented in owner-controls.tsx.
  • Nested dialog. The revoke confirmation is a base-ui dialog nested in the Share dialog. It was checked against the types and build, not in a browser.
  • Not done. No busy label on the settings password form. It is a plain HTML POST to a route, not a function action, so useFormStatus doesn't apply; D2 covers auth-form submit buttons.
  • Scope. No files outside the D1b ownership list were touched, apart from design.md and docs/motion.md (requested) and the tests/e2e/** specs listed above.

🤖 Generated with Claude Code

datj9 and others added 6 commits September 23, 2026 07:05
…al radiogroup

- One OwnerControlsProvider, seeded by page.tsx, holds the share list and live
  count for the Share dialog, the privacy switch and the Delete confirmation.
  The switch and Delete no longer re-read /shares on every open; the Share
  dialog's own re-read after a write is what they all show. A pure reducer
  drops out-of-order list answers and marks a confirmed revoke at once.
- page.tsx runs the handoff signing and both owner queries with Promise.all.
- Revoking a share link asks first, in a ConfirmDialog nested in the Share
  dialog; failure keeps it open with the error; focus returns to the Revoke
  button on cancel and to the row on success.
- Privacy radiogroup uses manual activation: arrows/Home/End move focus only,
  Space/Enter commits. A polite "Saved" status is announced after the PATCH.
- Busy labels on Create link, Revoke link and Delete.
- The artifact iframe is titled with the artifact's title (share page keeps
  "Artifact"); e2e specs select it by data-testid instead of title.
- The back link to the dashboard is a next/link.

E2E specs updated for the extra confirm click on revoke: share-link,
share-count-expiry, visibility-private-share-warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…alog

- User table: Deactivate, Make admin, Make member and Delete confirm through
  one ConfirmDialog; only Reactivate commits on the press. Row actions are
  described as data in user-actions.ts (unit-tested). Requests now catch
  network failures, and a refusal keeps the dialog open with the server's
  reason instead of closing it and showing the error behind the popup.
- Invite revoke asks first; failure stays in the dialog; focus lands on the
  row once its Revoke button is gone.
- Busy labels on Create invite, Create category, Rename, Activate/Deactivate,
  Apply filters, Load more and Reactivate.
- Admin nav and "Back to artifacts" use next/link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Provider key: a refused save shows the API envelope's message when it has
  one (e.g. the 422 VALIDATION_FAILED for a base URL on a blocked address),
  falling back to the generic line for anything else. Network failures are
  caught. Remove asks first and keeps failures in the dialog.
- API tokens: Revoke asks first; failure stays in the dialog; focus lands on
  the row once its Revoke button is gone. Create failures are caught.
- Busy labels on Save/Update key, Remove key, Create token, Revoke token.
- Settings nav and "Back to artifacts" use next/link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Both were removed in #57. design.md now says tokens are consumed as CSS
custom properties from globals.css and CSS modules; docs/motion.md replaces
the Sonner toast row with the in-place status-message rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Done

The DELETE's 204 reaches Playwright before the page has handled it, so the
revoke ConfirmDialog is still open, then in its 220 ms exit transition, when
the spec reaches for Done. getByText('Done') matched its description
("...cannot be undone...") as well as the Share dialog's Done button.

Wait for share-revoke-dialog to be hidden, then press the Done button by role
and exact name, scoped to the share dialog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shell is a one-column grid with an implicit `auto` column, which is
floored at the min-content width of its widest item. On /admin/users that is
the data table, so the column grew to ~640px and the header and nav (which
already wrap) stretched with it past the viewport. `minmax(0, 1fr)` pins the
column to the viewport; tables keep scrolling inside their overflow-x: auto
wrappers. The settings shell had the same latent pattern and only passed
because it has no table; it gets the same one-line fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@datj9 datj9 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🤖 Automated review (Claude Code)

Verdict: Looks good to merge after a look at the MEDIUM below. No blocking correctness or security defects in what this PR adds on top of #57.

Findings: 0 CRITICAL, 0 HIGH, 1 MEDIUM, 1 LOW

Checks (run locally on dcddc8e):

  • pnpm typecheck: pass
  • pnpm lint: pass
  • pnpm vitest run --project unit: 77 files, 1222 tests, all pass
  • Playwright: not run (no Docker)

Summary

  1. MEDIUM app/a/[id]/privacy-switch.tsx:132: the private-downgrade warning now trusts a page-lifetime liveCount, which can be too low (a link created from the CLI / another tab, or a failed post-create re-read). The downgrade then goes through without warning that a live link still opens. The docs in owner-controls.tsx say that staleness only ever overcounts, which does not hold for links created elsewhere.
  2. LOW app/a/[id]/share-dialog.tsx:215 (the same pattern in user-table, token, invite and key managers): onOpenChange is passed straight through, so Esc or a backdrop click closes the confirmation while the request is in flight. If that request fails, the error goes into the closed dialog's slot and nobody sees it. The user thinks it worked.

Everything else I checked holds up: the reducer's request-id ordering, the manual-activation radio keys, the Promise.all in page.tsx, the envelope-message fallback (length cap, non-JSON), the user-action request bodies, and focus restoration via finalFocus after a successful revoke.

if (next === 'private') {
void choosePrivateOrConfirm()
// Only a downgrade that leaves live links open needs asking about.
if (next === 'private' && liveShareLinks > 0) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

MEDIUM: stale-low liveCount skips the private-downgrade warning.

Before this PR, pressing Only me re-read /shares and so saw the current count. Now liveShareLinks is the page-seeded context value, which is only updated by this page's own Share dialog. It undercounts in two realistic cases:

  1. A link is created from the CLI (enclave share create) or another tab after this page loaded.
  2. A create here succeeds but the follow-up refreshShares() fails. That failure is swallowed in owner-controls.tsx, so the count stays at its old value.

In either case liveShareLinks === 0, so choose('private') runs with no confirmation. The owner believes the artifact is locked down, but a share link still opens it. That is the exact case this confirmation exists for.

The comment in owner-controls.tsx:25-27 says staleness "errs toward warning ... never toward staying silent". That only holds for revokes, not creates.

Cheap fix: before the private downgrade only (a rare, deliberate action), call refreshShares() and read the result, or keep the old one-shot read. Also consider bumping liveCount optimistically after a successful create when the re-read fails.

{/* Rendered inside the popup so base-ui treats it as a nested dialog: Esc closes only it. */}
<ConfirmDialog
open={isRevokeOpen}
onOpenChange={setIsRevokeOpen}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

LOW: the confirmation can be dismissed mid-request, and then its failure is lost.

ConfirmDialog forwards every onOpenChange (Esc, backdrop, Keep it) even while busy. So Esc during the DELETE closes the dialog. If the DELETE then fails, setRevokeError(REVOKE_FAILED) writes into a closed dialog, and the next requestRevoke clears it. The user sees nothing and the link is still live.

The same pattern appears in user-table.tsx:202, token-manager.tsx, invite-manager.tsx and key-manager.tsx.

Suggested fix: ignore close requests while busy, for example onOpenChange={(open) => { if (!open && isBusy) return; setIsRevokeOpen(open) }}. Or make that the default inside ConfirmDialog when busy.

…rite

The create/save/revoke handlers in the token, invite and provider-key
managers now catch errors, and their follow-up list read ran inside that
try. A network error on the re-read after a successful POST showed
'That did not work' next to a token/invite that had been created (or a
key that had been saved, with the form already reset). The re-reads now
swallow their own failures, like the user table and owner-controls do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@datj9

datj9 commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Review summary (independent review of the delta against refactor/ui-primitives-and-deps)

Fixed in bab3bd5:

  • CONFIRMED (low): token-manager, invite-manager, key-manager. This PR added catch blocks to the create/save/revoke handlers, but the list re-read after a successful write (refreshTokens / refresh / refreshStoredKey) ran inside that try. If the re-read hits a network error after a successful POST, the page shows "That did not work. Check the fields and try again." next to a token or invite that was in fact created. For a key it shows the error after the key was saved and the form already reset. The re-reads now swallow their own failures, which is what user-table's refresh and refreshShares already do. There's no component-test setup, so this has no unit test.

Left as is (PLAUSIBLE, low):

  • Cancel while busy: every ConfirmDialog caller lets Esc or "Keep it" close the dialog while the request is in flight. If the request then fails, its error goes into a dialog that is already closed and nobody sees it. If it succeeds, the action lands after the user cancelled. Fixing it means blocking close while busy, which is a ConfirmDialog (refactor(ui): shared ConfirmDialog, @base-ui/react, remove Tailwind and unused deps #57) or call-site policy decision.
  • user-table Delete success: the row is removed and no finalFocus is set, so focus falls to . The old DeleteUserDialog did the same.

Checked and rejected:

  • The requestId guard does drop out-of-order reads. markRevoked can't be overwritten by an older read in practice: create awaits its re-read while busy, and busy blocks revoke.
  • Nested dialog: base-ui 1.7 turns off the parent's escapeKey and outside press while a nested dialog is open, so Esc closes only the confirmation.
  • The initial liveCount comes from the server's countLiveShareLinks, which excludes expired links.
  • Radiogroup: Home/End and wrap work. Space and Enter go through the native button click, and the group isn't inside a form. The Saved region is always mounted and its timer is reset.
  • The key-manager envelope message is rendered as React text, so no HTML is interpreted, and it is capped at 300 chars.
  • Iframe title: titles are validated as trim().min(1).
  • The design.md and motion.md edits match the repo (styles/tokens.css and globals.css exist; there are no tailwind or sonner deps).
  • The e2e changes still assert a single DELETE and focus kept on the busy confirm button.

Gates: typecheck, lint, unit (1222 passed) and build all pass.

A successful Delete removed the row and the button that opened the
confirmation, so base-ui returned focus to a detached node and it fell
to <body>. The target is now picked before the request: the next row's
first action, else the previous row's, else that row itself (your own
row has no actions; rows get tabIndex=-1), else the table (tabIndex=-1).
The row is also removed locally on success, so it goes even if the
re-read fails. Other confirmed actions keep returning focus to their
trigger, which stays.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@datj9

datj9 commented Sep 23, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: a7a5169 fixes the user-table Delete focus finding. The target is picked before the request. It is the next row's first action, else the previous row's. If that neighbour is your own row, which has no actions, focus goes to the row itself (rows now have tabIndex=-1). If there is no other row, focus goes to the table, which now has tabIndex=-1 and aria-label="Accounts". On success the row is also removed locally, so it goes even if the re-read fails. Other confirmed actions still return focus to their trigger. There's no DOM test environment in the unit project, so no unit test. typecheck, lint, unit (1222) and build pass. I haven't touched cancel-while-busy; that fix is going into #57's ConfirmDialog.

datj9 and others added 2 commits September 23, 2026 08:10
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@datj9
datj9 changed the base branch from refactor/ui-primitives-and-deps to main September 24, 2026 23:17
@datj9
datj9 merged commit b0c7ce7 into main Sep 24, 2026
8 of 12 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