Repository navigation
feat(users): admin can toggle admin status and delete any user but self - #276
Conversation
Adds PUT /api/admin/hub/users/:id/admin (owner-only, { isAdmin: boolean })
and lifts the blanket "Cannot delete admin users" refusal from DELETE
/api/admin/hub/users/:id. An admin may now promote, demote, and delete any
account except their own.
Self is rejected on both endpoints, which is what makes a last-admin guard
unnecessary: the acting admin is never a valid target, so at least one admin
always survives either operation.
Deleting an admin surfaced a latent 500. instances.owner_id is NOT NULL
REFERENCES users(id) with no ON DELETE CASCADE, and every admission site
picks the owner with an unordered `SELECT id FROM users LIMIT 1` — so any
user may hold instance rows, and deleting them threw SQLITE_CONSTRAINT. The
handler now reassigns owned rows to the acting admin inside the delete
transaction. That column turns out to be write-only dead weight; #275 tracks
removing it and this workaround with it.
Also extracts SYSTEM_USERNAME to hub/src/db/system-user.ts, replacing three
scattered '__system__' literals. The placeholder is rejected as a target for
both operations.
Frontend: user rows get icon+text action pills matching PeersSection
(Password / Make admin / Revoke admin / Remove) instead of ambiguous
icon-only buttons, with delete behind a confirm dialog that names the user
and warns when the target is an admin. Mutation errors surface inline.
closes #274
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
FROM @claude:
|
Review findings on #276. Backend, all in `routes/admin.ts`: - Extract `loadTargetUser()` — the self / `__system__` / owner guard set shared by demote, delete, and password. The three handlers had been repeating a lookup that each new guard would have to be added to twice. - Refuse demotion and deletion of the `POUTINE_OWNER_USERNAME` row. `seedOwner` re-seeds only when the users table holds no real accounts and `reset-password.sh` errors on a missing user, so the loss is permanent — and the DLNA browse path authenticates as that account's u+p, so a delete makes every Browse fail Subsonic auth and `routes/dlna.ts` quietly serves an empty container. Promotion back to admin stays allowed as the recovery path. - Reserve `__system__` in `POST /users`, and apply the placeholder guard to `PUT /users/:id/password`. The name is load-bearing — hidden from `GET /users`, refused by every mutation, uncounted by `seedOwner` — so a real account holding it would be invisible and undeletable. - Refuse a demote or delete that would leave zero admins. `requireOwner` reads `is_admin` in the preHandler, so two admins can both be authorized before either write lands; `otherAdminCount()` runs in the handler body, synchronously with the write, so the second request sees the first. Frontend `UsersSection.tsx`: - Pick the error from whichever mutation has the newer `submittedAt`. `delete ?? admin` pinned a stale delete failure to the row and swallowed every later toggle error. - Warn about peer reassignment unconditionally. Non-admins own `instances` rows too, so gating the sentence on `isAdmin` re-homed guest-owned peer records silently. The last-admin guard is asserted directly against the post-race DB state rather than by racing two `app.inject` calls — those pass with the guard removed, since inject serializes and the real window is between the preHandler read and the handler. Corrects the pitfalls.md claim that a last-admin guard was unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
FROM @claude:
|
Resolve conflicts with #272 (user invitations): keep both sections in docs/authentication.md and both API helper blocks in frontend/src/lib/api.ts. Document that deleting a user cascades to the invitations they issued. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNTqdCoTsmaZ6cqH1x5VRn
Closes #274.
What
PUT /api/admin/hub/users/:id/admin(owner-only), body{ isAdmin: boolean }→ 204.DELETE /api/admin/hub/users/:id— drops the blanketCannot delete admin usersrefusal.An admin may now promote, demote, and delete any account except their own.
__system__isAdminrequireOwner)No last-admin guard, deliberately. Self is never a valid target for either operation, so the acting admin always survives — a count-based guard would be unreachable. Recorded in
docs/pitfalls.mdso it does not get "fixed" later.The latent 500 this surfaced
instances.owner_idisNOT NULL REFERENCES users(id)with noON DELETE CASCADE, and every admission site picks the owner with an unorderedSELECT id FROM users LIMIT 1. So an arbitrary user — including a non-admin guest — can end up holding instance rows, and deleting them threwSQLITE_CONSTRAINT→ 500.This is not hypothetical: on the live hub a non-admin guest owned two peer instance rows. The handler now reassigns owned rows to the acting admin inside the delete transaction.
That column turns out to be read by nothing at all — not the UI, not any API response, not any authz check. #275 tracks dropping it along with this workaround.
Frontend
Row actions become icon+text pills matching
PeersSection, replacing icon-only buttons that could not distinguish state ("is an admin") from action ("click to make admin"):Delete is behind a
window.confirm(same pattern asPeersSection/CacheSection) that names the user and warns when the target is an admin. Mutation errors render inline.Notes
requireOwner/requireAuthre-read theusersrow per request, so a demotion 403s and a deletion 401s on the next call. Documented indocs/authentication.md.SYSTEM_USERNAMEtohub/src/db/system-user.ts, replacing three scattered'__system__'literals.Testing
hub/test/admin-routes.test.ts(promote/demote round-trip incl. real access changes via login, all guards,owner_idreassignment, cascade of stars/play events).UsersSection.test.tsx(both toggle directions, self-row hiding, confirm accept/decline, admin-specific confirm wording, error surfacing).pnpm verify: typecheck + lint clean, 839 hub + 175 frontend tests pass.pnpm test:federation: 84 subsonic-compat tests green across hub-a/b/c plus tombstone-gossip and re-admission flows.dlna-ssdp.integration.test.ts(3 tests), reproduced identically onmain— the known flake in flaky: dlna-ssdp integration test 'M-SEARCH with ssdp:all' intermittently sees zero replies #268.navidrome: ok, no application-level warnings, data intact.🤖 Generated with Claude Code