add /settings/user-management

The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.

API — all owner-only, mounted on the existing users router:
  GET    /api/users            list, plus the role enum so the UI never
                               hand-writes the names
  PATCH  /api/users/:id/role   change a role
  DELETE /api/users/:id        remove an account

ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.

The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.

The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.

The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.

Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.

Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.

Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 14:57:48 +00:00
co-authored by Claude Opus 5
parent 4ccdb8a1fa
commit a328451250
9 changed files with 333 additions and 4 deletions
+84
View File
@@ -0,0 +1,84 @@
import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin.
//
// The password hash never leaves this file — listing users is not a reason to hand them out.
type PublicUser = {
id: number;
email: string;
name: string | null;
username: string | null;
avatar: string | null;
status: string;
role: string;
createdAt: Date;
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
isOwner: boolean;
};
const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
id: u.id,
email: u.email,
name: u.name,
username: u.username,
avatar: u.avatar,
status: u.status,
role: u.role,
createdAt: u.createdAt,
isOwner: u.id === OWNER_USER_ID,
});
export const listUsersHandler: Handler = async function (ctx) {
const users = await getUsers();
return ctx.json({
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
roles: USER_ROLES,
ownerId: OWNER_USER_ID,
});
};
export const updateUserRoleHandler: Handler = async function (ctx) {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
const { role } = ctx.get('body') as { role?: unknown };
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
}
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
// message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
// clicking the dropdown nothing about why.
if (id === OWNER_USER_ID && role !== 'Super Admin') {
throw errors.FORBIDDEN('The server owner cannot be demoted.');
}
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
const updated = await updateUser(id, { role: role as UserRole });
if (!updated) throw errors.NOT_FOUND('User not found');
return ctx.json({ user: toPublicUser(updated) });
};
export const deleteUserHandler: Handler = async function (ctx) {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
// deleteUser() throws for the owner regardless; this turns it into a 403 with a sentence rather than
// an unhandled error.
if (id === OWNER_USER_ID) throw errors.FORBIDDEN('The server owner cannot be removed.');
const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found');
// Deleting a user cascades: passkeys, dashboards, screens, email accounts, playlists, everything keyed
// to them. There is no undo, which is why the UI asks first.
await deleteUser(id);
return ctx.json({ ok: true });
};
+18 -2
View File
@@ -1,10 +1,26 @@
import type { MiddlewareHandler } from 'hono';
import { createRouter } from '@@/create-router';
import { originMiddleware } from '@@/_middlewares';
import { isSuperAdmin } from '@@/super-admin';
import * as errors from '@@/custom-errors';
import { updateUserHandler } from './update-user';
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
// Self-update. Officer is single-user: the server owner is the only account, so there is no user
// listing, invitation or deletion — the account is created once by /auth/bootstrap.
// Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler);
// Everything below manages OTHER accounts and is the owner's alone. The global backstop in
// originScopeMiddleware already confines a non-owner token to /api/auth + /api/music, so a Member
// cannot reach these at all; this gate is the explicit statement of intent and gives a clear 403
// rather than relying on a rule written for a different purpose.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
return next();
};
usersRouter.get('/', ownerGate, listUsersHandler);
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);