Skip to content

ui: let operators manage a user from the user page - #1253

Merged
ac-pplx merged 7 commits into
ac/admin-ui-not-found-statesfrom
ac/admin-ui-users
Nov 3, 2026
Merged

ac-pplx merged 7 commits into
ac/admin-ui-not-found-statesfrom
ac/admin-ui-users

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Operators can now manage a user from the user page instead of through curl.

Before this change:

  • The user detail page showed five fields and a role select.
  • Resetting a user's password, the step docs/installation.md documents through PATCH /admin/api/v1/users/{id}, needed curl, and so did correcting a name or email.
  • The page did not link to the user's organization, sandboxes or keys.
  • Role changes, including promotion to organization admin of the managing organization (operator access to this console), applied on a single click.

Changes:

  • User detail:
    • Edit (name, email), Set password (entered twice, at most 72 bytes, the bcrypt limit, counted as bytes) and Delete, each behind a modal or confirmation. Edit opens with the current values, also after a save (each open gets a fresh form), and its email rule accepts seeded accounts such as dev@local. Both delete confirmations, here and in the Users table, say: "Their sessions end. API keys they created are not revoked and keep authenticating for the organization; delete them on the API keys page first if this user's access should end. The delete is refused while they own sandboxes or archived sandboxes." (api_keys.created_by is ON DELETE SET NULL; the sandbox refusal is a foreign key on sandboxes.user_id, which the admin API currently answers with a 500.) Delete is disabled, with that reason, while the page lists sandboxes the user owns. A refused delete is explained as sandbox ownership only for a 500 when the user's sandboxes are known to exist (the Users table reads the organization's sandboxes on a 500 to tell); a code comment says this stays until the server answers the foreign-key refusal with its own status. Any other failure shows the API's message, including a 409, which the admin API returns today only when an operator tries to delete themselves, and a retryable 503;
    • on your own page, Edit and Set password are offered, as the API allows; the role select and Delete are not, because the API refuses both for a self-target;
    • under external sign-in, your own email is read-only, with the reason shown: the provider's email is matched exactly against it, so saving a changed one would lock you out, and only another administrator of the managing organization could change it back. Your display name stays editable; password-mode self-edits and edits of other users are unchanged;
    • every save reloads the user, and only the newest reload is shown, so two saves in flight together do not leave an outdated role on screen. When that reload fails, the page shows the save's own answer (under the same newest-request check) and says "Saved; the page could not reload the user: " with Retry, so a later Edit starts from what was saved, not from the user as it was before;
    • Set password is hidden under external console sign-in. The API also refuses a local password for a federated user, but its user response does not say whether a user is federated, so the page cannot hide the button for them; it shows the API's refusal instead;
    • setting a password ends every session of that user, and the dialog says so. After you change your own, the console clears its sign-in and the login page asks you to sign in with the new password. Resetting another user's password leaves your session alone;
    • the organization is a link;
    • the user's sandboxes are listed, from the organization's newest 5000 (the size the organization page reads), with a link to the organization's sandbox list. That link opens the sandbox list filtered to the organization with ?org=, which api, ui: show and filter sandboxes by organization #1248 adds;
    • the API keys the user created are listed;
    • each of the two lists shows its own error with Retry if it fails to load, and Delete says "sandbox list unavailable" when the sandbox list failed.
  • Role changes on the user page and in the Users table ask for confirmation, and say what organization admin means: console access only for a user in the organization that manages the cluster; for anyone else, that the role does not give admin-console access. When the cluster record did not load, the text says that whether the role gives console access could not be determined.
  • Users page opens on the signed-in admin's own organization instead of the first one listed.
  • Organization detail shows role labels ("Organization Admin") instead of raw keys (organization_admin).

Stack

Stacked on #1247, which adds the shared load-failure handling this page uses. Merge #1247 first. The "organization's sandbox list" link filters with ?org=, which #1248 adds, so #1248 must merge first for that link to filter. #1262 (organization page) is stacked on this PR.

Validation

  • npm ci, npm test (60 pass, including src/lib/user-delete.test.mjs for the delete text, the refusal rules by status and the byte-counted password limit, and src/lib/user-roles.test.mjs for the role-change text, including an unknown cluster record) and npm run build in ui/.
  • A Playwright loop against a local Kind stack with synthetic data, on the parent branch (production build) and on this branch (the Kind space-ui image):
    • creating a user, then changing the role (the confirmation appears and the change persists in the API), then deleting the user;
    • setting a seeded user's password, after which that user signs in;
    • user detail shows the organization link, sandboxes and keys;
    • Edit then Save on dev@local saves;
    • your own page offers Edit and Set password but no role select or Delete;
    • synthetic 503 and 500 answers to a delete show the API message;
    • a failed sandbox or key list shows its own error, Retry reloads it, and Delete says the sandbox list is unavailable;
    • a 25-character, 75-byte password is refused as over 72 bytes;
    • under synthetic external sign-in, Set password is hidden;
    • under synthetic external sign-in, your own email is read-only and a save sends the display name only;
    • the password dialog says sessions end, and a (synthetically answered) change of your own password signs you out with a message on the login page;
    • after a save whose answer carries an outdated role, the page shows the reloaded user;
    • after a save whose reload answers a (synthetic) 503, the page shows the saved email and the reload warning, a later Edit starts from the saved email, and Retry clears the warning;
    • the login page shows the notice a password change leaves for it;
    • the Users page defaults to the signed-in admin's organization.
  • No new console errors, page errors or failed requests compared with main.
  • npm test (60 pass) now expects the API's message for a 409, with and without owned sandboxes, and npx tsc --noEmit -p . reports no errors. In the server, delete_user (crates/libs/api/src/routes/admin/identity.rs) returns 409 only for a self-target ("cannot modify your own role or delete yourself"), and the DELETE FROM users foreign-key refusal reaches the client as a 500.
  • A Playwright run on the same Kind stack compared production builds of an earlier head of this branch and of this head. Delete was confirmed on a seeded user and the request answered with a synthetic 409 carrying that message, so no user was deleted. The earlier head showed the sandbox-ownership explanation; this head shows the API's message.

Visual confirmation

Before After
before after

Setting a password:

after: set password

Video: after.

A delete answered with a synthetic 409, on an earlier head of this branch and on this head: the page now shows the API's message instead of the ownership explanation:

Before After
before after

Severity and risk

Severity: medium. Password resets are a routine support task.

Risk: low. Every action uses existing admin routes with the same permission checks. Roll back by reverting the commit.

AI assistance: Claude Code wrote the code and this description, and ran the validation; a maintainer reviews it.

@github-actions github-actions Bot added the triage:pending Waiting for a maintainer to triage label Oct 31, 2026
@github-actions

Copy link
Copy Markdown

Thanks for opening this pull request! A SPACE maintainer will take a look.

CONTRIBUTING.md explains how we review outside contributions.

zibi-pplx
zibi-pplx previously approved these changes Nov 1, 2026

@zibi-pplx zibi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The server enforces the role, delete and password rules, and the UI now adds confirmations for each. One note on how a refused delete is explained.

  • [low] The 409 branch labels the wrong refusal as sandbox ownership.
    ui/src/lib/user-delete.ts:21: today, delete_user answers 409 only for SelfMutation ("cannot modify your own role or delete yourself", crates/libs/api/src/routes/admin/identity.rs:35, mapped at :84). The foreign-key refusal comes back as a 500, because the handler runs a plain DELETE (identity.rs:453) and the error converts to Internal. So a 409 would be relabelled "owns sandboxes". It isn't reachable now, because Delete is hidden on your own row.
    • Suggestion: keep the API's message for a 409 until the server answers the ownership refusal with its own status.
    • Suggestion: open a tracking issue for returning a 4xx on that foreign-key violation, so the 500 heuristic, which reads the org's sandboxes on a 500, can go once it lands.

@zibi-pplx
zibi-pplx dismissed their stale review November 1, 2026 22:30

Withdrawing this approval for now. Any feedback above still stands.

@ac-pplx
ac-pplx added this pull request to stack #1282 November 2, 2026 04:30
The user detail page showed five fields and a role select. Resetting a
user's password, which docs/installation.md documents through
PATCH /admin/api/v1/users/{id}, needed curl; so did correcting a name or
email. The page did not link to the user's organization, sandboxes or
keys, and role changes, including promotion to organization admin of
the managing organization, applied on a single click.

User detail now has Edit (name, email), Set password and Delete, links
the organization, and lists the user's sandboxes (among the org's newest
1000) and the API keys they created. Role changes on the user page and
in the Users table ask for confirmation first. The Users page opens on
the signed-in admin's own organization instead of the first one listed,
and organization detail shows role labels instead of raw role keys.
The user page's delete confirmation said a deleted user's sandboxes and
API keys stay with the organization. Deleting a user removes the users
row: sessions and credentials go with it, API keys they created stay
without a creator, and the database refuses the delete while the user
owns any sandbox or archived sandbox, which the admin API currently
reports as a 500.

Both delete confirmations (user page and Users table) now say that. On
the user page Delete is disabled, with the reason, when the user's
sandbox list is not empty; that list covers the organization's newest
1000, so a refused delete is still possible and now reads as an
explanation of the ownership rule rather than "internal server error".
- Both delete confirmations say that API keys the user created are not
  revoked and keep authenticating for the organization.
- A refused delete is explained as sandbox ownership only for a 409, or
  a 500 when the user's sandboxes are known to exist; any other failure,
  including a retryable 503, shows the API's message. The users list
  reads the org's sandboxes on a 500 to tell.
- Edit opened with empty fields, because the values were set before the
  dialog's form existed; they are now its initial values. The email
  rule no longer rejects seeded accounts such as dev@local.
- Your own page offers Edit and Set password, as the API allows; the
  role and Delete stay hidden. Set password is hidden in external
  sign-in.
- The sandbox and API key lists each show their own error with Retry
  instead of loading forever, and Delete says when the sandbox list is
  unavailable. The sandbox list reads the org's newest 5000, as the org
  page does.
- The password limit is 72 bytes, checked as bytes. The role-change
  text promises console access only for users in the managing org.
- Under external sign-in your own email is read-only: the provider's
  email is matched exactly against it, so saving a changed one would
  lock you out with no way back. The display name stays editable;
  password-mode self-edits and edits of other users are unchanged.
- Setting a password ends every session of that user. The dialog says
  so; after changing your own, the console clears its sign-in and the
  login page asks you to sign in with the new password. Resetting
  another user's password leaves your session alone.
- A save no longer puts its own copy of the user on screen: each save
  reloads the user, and only the newest reload applies, so a slower
  save cannot restore an outdated role.
- When the cluster record did not load, the role-change text says
  whether the role gives console access could not be determined,
  instead of saying it gives none.
After a successful save the page reloads the user and drops the answer
when that reload fails, so it kept showing the user as before the save.
A later Edit then started from that copy and sent the replaced email
back: save email B, the reload answers 503, the page still shows A, and
the next display-name edit writes A again.

When the reload fails, the page now applies the save's own answer,
under the same newest-request check, and says "Saved; the page could
not reload the user: <error>" with Retry.

The login page took its one-time notice in an effect that set the
notice to whatever it took, so a second run of the effect (React Strict
Mode in development) would set it back to nothing. It now keeps what
the first run took.
The edit dialog kept one form store across opens. Its form remounts on
each open, but a remounted form's initial values do not replace what
the store still holds, so after a save the dialog reopened with the
values of the previous open: edit the email, save, open Edit again, and
it showed the old email, which a second save wrote back. Development
builds hid it, because React Strict Mode mounts the form twice.

The dialog is now its own component, mounted only while open, with its
own form, so each open starts from the user as the page shows it.
The delete failure explained every 409 as the user owning sandboxes,
but the admin API answers 409 only when callers try to delete
themselves; the database's ownership refusal comes back as a 500. A
409 now shows the API's message. The 500 explanation, used while the
page shows the user owns sandboxes, stays until the API answers that
refusal with its own status, as the comment says.

@johnlouie-pplx johnlouie-pplx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving.

Since 5d00c2d the only change is d9b00da: a 409 now keeps the API's message. That's right, because delete_user returns 409 only for SelfMutation, and the delete text no longer blames sandbox ownership for it. Mutating the 409 back to the ownership text fails the updated test. I also rechecked the parts main touched under the rebase. require_admin_auth, delete_user and the users/sessions/api-keys foreign keys are unchanged, and the role confirmation's unknown-cluster branch covers main's new null cluster. I ran npm test (151 pass) and tsc on d9b00da, plus next build on the stack head. I didn't run it in a browser or against a live API.

Non-blocking: the ownership refusal still comes back as a 500 from delete_user, so the 500-plus-visible-sandboxes heuristic, and the Users table's extra read, stay until the API maps the sandboxes_user_id_fkey violation to its own status. Worth a small API follow-up when you get to it.

@ac-pplx
ac-pplx added this pull request to the merge queue Nov 3, 2026
@ac-pplx

ac-pplx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. That API follow-up is #1303, which is in review. DELETE /orgs/{org}/users/{user} answers 409 with a reason instead of a 500 when the user still owns sandboxes: user_owns_sandboxes, user_owns_retained_deleted_sandboxes with retained_until, or user_owns_archived_sandboxes. Once both are in, the console's 500-plus-visible-sandboxes heuristic and the Users table's extra read can go; I'll drop them in the console follow-up.

Merged via the queue into main with commit 12b4bc3 Nov 3, 2026
23 checks passed
@ac-pplx
ac-pplx deleted the ac/admin-ui-users branch November 3, 2026 04:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage:pending Waiting for a maintainer to triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants