feat(server/m7-user-mgmt): admin endpoints (invites, users, promote/demote)
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>
This commit is contained in:
@@ -11,6 +11,17 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countAdmins = `-- name: CountAdmins :one
|
||||
SELECT count(*) FROM users WHERE is_admin = true
|
||||
`
|
||||
|
||||
func (q *Queries) CountAdmins(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAdmins)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countUsers = `-- name: CountUsers :one
|
||||
SELECT count(*) FROM users
|
||||
`
|
||||
@@ -205,6 +216,47 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, display_name, is_admin, created_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
type ListUsersRow struct {
|
||||
ID pgtype.UUID
|
||||
Username string
|
||||
DisplayName *string
|
||||
IsAdmin bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Admin user-management list. Sort newest-first.
|
||||
func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
||||
rows, err := q.db.Query(ctx, listUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUsersRow
|
||||
for rows.Next() {
|
||||
var i ListUsersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.DisplayName,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
||||
UPDATE users
|
||||
SET listenbrainz_enabled = $2
|
||||
@@ -253,3 +305,35 @@ func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswo
|
||||
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
||||
UPDATE users
|
||||
SET is_admin = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name
|
||||
`
|
||||
|
||||
type UpdateUserAdminParams struct {
|
||||
ID pgtype.UUID
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// Sets is_admin to the given value. Returns the updated row so the
|
||||
// handler can echo it back to the caller.
|
||||
func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams) (User, error) {
|
||||
row := q.db.QueryRow(ctx, updateUserAdmin, arg.ID, arg.IsAdmin)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.ApiToken,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
&i.ListenbrainzToken,
|
||||
&i.ListenbrainzEnabled,
|
||||
&i.DisplayName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user