bd362ab384
Five admin endpoints under existing RequireAdmin middleware:
- GET /api/admin/invites — list active + recently-redeemed
- POST /api/admin/invites — generate 24h invite, returns
{token, expires_at, ...}. Audits ActionInviteCreate.
- DELETE /api/admin/invites/{token} — revoke unredeemed invite.
Audits ActionInviteRevoke.
- GET /api/admin/users — list all users (id, username,
display_name, is_admin, created_at).
- PUT /api/admin/users/{id}/admin — toggle is_admin with
last-admin guard. Audits ActionPromoteAdmin / ActionDemoteAdmin.
Last-admin guard counts admins, refuses demotion of the sole
admin with 409 'last_admin'. Race window between count and update
is acceptable for v1 — worst case is 'no admins left,' which the
env-driven bootstrap or CLI reset can recover from. Common path
('admin demotes themselves') is now blocked.
Adds ListUsers, CountAdmins, UpdateUserAdmin sqlc queries to
users.sql.
Tests cover: invite create/list/delete round-trip, non-admin gets
403, user list, promote happy path, last-admin demotion refused,
two-admins demotion allowed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
84 lines
2.7 KiB
SQL
84 lines
2.7 KiB
SQL
-- name: CreateUser :one
|
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- name: CreateUserFirstAdminRace :one
|
|
-- Inserts a new user; sets is_admin=true ONLY when no users currently
|
|
-- exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT
|
|
-- time within the same statement, so the result reflects committed
|
|
-- state at that moment.
|
|
--
|
|
-- Race semantics: two concurrent calls in an empty-users state may
|
|
-- both observe "no users" and both insert with is_admin=true. That's
|
|
-- benign — having two admins from the gate is fine; what matters is
|
|
-- that there's AT LEAST one. If the two calls happened to share the
|
|
-- same username, the unique constraint on users.username arbitrates
|
|
-- and the second caller's INSERT fails with a unique violation. The
|
|
-- caller (registration handler) can retry as a regular non-admin in
|
|
-- that case (or surface a "username taken" error to the user).
|
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
|
VALUES (
|
|
$1, $2, $3,
|
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
|
$4
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetUserByUsername :one
|
|
SELECT * FROM users WHERE username = $1;
|
|
|
|
-- name: GetUserByAPIToken :one
|
|
SELECT * FROM users WHERE api_token = $1;
|
|
|
|
-- name: CountUsers :one
|
|
SELECT count(*) FROM users;
|
|
|
|
-- name: SetSubsonicPassword :exec
|
|
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
|
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
|
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
|
|
|
-- name: GetUserByID :one
|
|
SELECT * FROM users WHERE id = $1;
|
|
|
|
-- name: SetListenBrainzToken :exec
|
|
UPDATE users
|
|
SET listenbrainz_token = $2,
|
|
listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END
|
|
WHERE id = $1;
|
|
|
|
-- name: SetListenBrainzEnabled :exec
|
|
UPDATE users
|
|
SET listenbrainz_enabled = $2
|
|
WHERE id = $1;
|
|
|
|
-- name: ListUsers :many
|
|
-- Admin user-management list. Sort newest-first.
|
|
SELECT id, username, display_name, is_admin, created_at
|
|
FROM users
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: CountAdmins :one
|
|
SELECT count(*) FROM users WHERE is_admin = true;
|
|
|
|
-- name: UpdateUserAdmin :one
|
|
-- Sets is_admin to the given value. Returns the updated row so the
|
|
-- handler can echo it back to the caller.
|
|
UPDATE users
|
|
SET is_admin = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetListenBrainzConfig :one
|
|
-- Returns the user's LB token + enabled flag and the most recent
|
|
-- play_events.scrobbled_at for last-scrobbled-at status.
|
|
SELECT
|
|
u.listenbrainz_token,
|
|
u.listenbrainz_enabled,
|
|
(SELECT MAX(pe.scrobbled_at)::timestamptz
|
|
FROM play_events pe
|
|
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
|
FROM users u
|
|
WHERE u.id = $1;
|