import type { OfficerContext } from './routes'; import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; import { activeClient } from './active'; import { toUser, toNode, arrayField, type OfficerUser } from './normalize'; // User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys. // // The list is enriched with a node count, which the admin API does not provide: deleting a user takes its // nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a // surprise. That is one extra upstream call for the whole list, not one per user. export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number }; async function listUsers(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]); const nodes = arrayField(nodeBody, 'nodes').map(toNode); const counts = new Map(); for (const node of nodes) { const id = node.user?.id; if (!id) continue; const entry = counts.get(id) ?? { total: 0, online: 0 }; entry.total += 1; if (node.online) entry.online += 1; counts.set(id, entry); } const users: UserWithCounts[] = arrayField(userBody, 'users') .map(toUser) .filter((u): u is OfficerUser => !!u) .map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 })) .sort((a, b) => a.name.localeCompare(b.name)); return Response.json({ users }); } async function createUser(ctx: OfficerContext): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; const body = await readJson(ctx.req); if (!body) return badRequest('expected a JSON body'); const name = typeof body.name === 'string' ? body.name.trim() : ''; if (!name) return badRequest('name is required'); const created = await client.call<{ user?: Record }>('/api/v1/user', { method: 'POST', body: { name, displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined, email: typeof body.email === 'string' ? body.email.trim() : undefined, }, }); return Response.json({ user: toUser(created.user) }, { status: 201 }); } type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined }; async function handleUserAction({ ctx, id, action }: UserActionParams): Promise { const client = await activeClient(ctx.userId); if (client instanceof Response) return client; if (action === 'rename') { if (ctx.req.method !== 'POST') return methodNotAllowed(); const body = await readJson(ctx.req); if (!body) return badRequest('expected a JSON body'); const name = typeof body.name === 'string' ? body.name.trim() : ''; if (!name) return badRequest('name is required'); // Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident. const renamed = await client.call<{ user?: Record }>( `/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' }, ); return Response.json({ user: toUser(renamed.user) }); } if (action !== undefined) return notFound(); if (ctx.req.method === 'DELETE') { // Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim. await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' }); return new Response(null, { status: 204 }); } return methodNotAllowed(); } /** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */ export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise { if (rest.length === 0) { if (ctx.req.method === 'GET') return listUsers(ctx); if (ctx.req.method === 'POST') return createUser(ctx); return methodNotAllowed(); } if (rest.length > 2) return notFound(); const id = rest[0]; if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric'); return handleUserAction({ ctx, id, action: rest[1] }); }