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'; import { OS_USERS_ENABLED } from '@@/os-user'; // 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; /** The Linux account this runs as, or null where per-user OS accounts are off. */ osUser: string | null; /** * The public half of their generated SSH key. Listed because it has an errand attached — it must be * added to their Gitea account — and the create form promises it is retrievable here afterwards. Public * by definition, so no reason to withhold it from the owner-only endpoint that already returns emails. */ osSshPublicKey: string | null; }; /** Shared with create-user.ts, so a created account and a listed one are described the same way. */ export const toPublicUser = (u: Awaited>[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, osUser: u.osUser, osSshPublicKey: u.osSshPublicKey, }); 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), // Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a // picker — both write paths refuse it — which is what `assignableRoles` is for. roles: USER_ROLES, assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'), ownerId: OWNER_USER_ID, // So the UI offers the Linux-account controls only where they can work. On a host without the feature // they would be a button that always reports the same refusal. osUsersEnabled: OS_USERS_ENABLED, }); }; 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.'); } // And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a // second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the // owner by that role, so two holders make "who owns this server" a question the query plan answers. // It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the // server over is a deliberate act, not a dropdown. if (id !== OWNER_USER_ID && role === 'Super Admin') { throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.'); } 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 }); };