diff --git a/TODO.md b/TODO.md index d00597aa..a1dd82c7 100644 --- a/TODO.md +++ b/TODO.md @@ -119,6 +119,18 @@ day. Kept here for what the policy work turned up, which is not obvious from the - [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale. +- [x] **Device invites, platform side** (`COMMS/OFFSCALE_INVITE_ENROLLMENT.md` §5, built 2026-08-05). + Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke — + `/_officer/enroll/invites` in `invites.ts`, `InvitesView`. **The records are not Officer's.** They + proxy to the server's Officer Companion, because the joining phone has to claim without an Officer + account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when + the platform is down" argument says the same. Officer stores no invite and no claim token. + **Waiting on the companion:** `/officer-api/enroll/*` is not deployed anywhere yet, so the section + renders its unavailable state. Four gaps were sent back to the spec author: `keys.ts` has no + sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable + behind nginx, `tailnet` is not a headscale concept and has to be recorded on the invite, and + `/api/v1/enroll/*` collides with headscale's own namespace. + ## Known bugs - [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale diff --git a/src/servers/sidecar/headscale/companion.ts b/src/servers/sidecar/headscale/companion.ts index 190f1201..852d914e 100644 --- a/src/servers/sidecar/headscale/companion.ts +++ b/src/servers/sidecar/headscale/companion.ts @@ -26,26 +26,31 @@ import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './r * companion is a state to render, not a request that failed — the admin API on the same domain is * independent and may still be working, so this must not surface as an error the UI swallows. */ -const unavailable = (reason: string) => ({ available: false as const, reason }); +export const unavailable = (reason: string) => ({ available: false as const, reason }); const DEFAULT_TIMEOUT_MS = 20_000; -type CompanionCall = { path: string; method?: string; timeoutMs?: number; signal?: AbortSignal }; +type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal }; /** * One request to a server's companion. Returns the raw Response, or a reason string when the companion * itself could not be reached — the caller decides how to present that, because for this feature * "unreachable" is information rather than a failure. */ -async function callCompanion( +export async function callCompanion( creds: HeadscaleServerCredentials, - { path, method = 'GET', timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall, + { path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall, ): Promise { let res: Response; try { res = await fetch(`${creds.url}/officer-api${path}`, { method, - headers: { authorization: `Bearer ${creds.apiKey}`, accept: 'application/json' }, + headers: { + authorization: `Bearer ${creds.apiKey}`, + accept: 'application/json', + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), signal: signal ?? AbortSignal.timeout(timeoutMs), }); } catch (err) { @@ -67,7 +72,7 @@ async function callCompanion( } /** Parse a companion JSON body, or a reason when it isn't JSON after all. */ -async function readBody(res: Response): Promise | string> { +export async function readBody(res: Response): Promise | string> { const text = await res.text().catch(() => ''); if (!text) return 'the companion returned an empty body'; try { @@ -80,7 +85,7 @@ async function readBody(res: Response): Promise | string } /** The active server's credentials, or a 409 the UI already knows how to render. */ -async function activeCreds(userId: number): Promise { +export async function activeCreds(userId: number): Promise { const creds = await getActiveHeadscaleCredentials(userId); if (!creds) { return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); diff --git a/src/servers/sidecar/headscale/enroll.ts b/src/servers/sidecar/headscale/enroll.ts index 6d8ae143..d0547ca1 100644 --- a/src/servers/sidecar/headscale/enroll.ts +++ b/src/servers/sidecar/headscale/enroll.ts @@ -4,6 +4,7 @@ import { getActiveHeadscaleCredentials } from 'officerdb'; import { badRequest, methodNotAllowed, readJson } from './routes'; import { createClient, type HeadscaleClient } from './client'; import { arrayField, toUser } from './normalize'; +import { handleInvitesRoute } from './invites'; // Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated // Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand. @@ -64,6 +65,11 @@ async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promi } export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise { + // `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device + // is not here and there is no Officer session on it. Same prefix because it is the same feature to the + // person using it, and because the spec names it that way. + if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1)); + if (segments.length > 0) return null; if (ctx.req.method !== 'POST') return methodNotAllowed(); diff --git a/src/servers/sidecar/headscale/invites.ts b/src/servers/sidecar/headscale/invites.ts new file mode 100644 index 00000000..9f27199b --- /dev/null +++ b/src/servers/sidecar/headscale/invites.ts @@ -0,0 +1,128 @@ +import type { HeadscaleServerCredentials } from 'officerdb'; +import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; +import { activeCreds, callCompanion, readBody, unavailable } from './companion'; + +// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use +// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth +// key it never had to be told. +// +// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining +// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an +// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when +// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the +// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what +// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke. +// +// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond +// the moment it is handed to the admin." The create response passes through this process once, in memory, +// on its way to the browser — that is the whole of its life here. +// +// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other +// companion route: most registered servers have no companion at all, and that is a state to render rather +// than a request that failed. + +/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */ +const DEFAULT_TTL_SECONDS = 900; +const MIN_TTL_SECONDS = 60; +const MAX_TTL_SECONDS = 86_400; + +type CreateInput = { + user: string; + ttlSeconds: number; + ephemeral: boolean; + tags: string[]; + note?: string; +}; + +/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */ +function parseCreate(body: Record | null): CreateInput | Response { + const user = typeof body?.user === 'string' ? body.user.trim() : ''; + if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user'); + + const raw = body?.ttlSeconds; + const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw); + if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) { + return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`); + } + + // Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and + // adding it here means the admin can type either form without minting a key that silently has no tag. + const tags = Array.isArray(body?.tags) + ? [ + ...new Set( + body.tags + .filter((t): t is string => typeof t === 'string') + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)), + ), + ] + : []; + + const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : ''; + + return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) }; +} + +/** + * Turn a companion answer into ours. + * + * The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false` + * (render an explanation), a companion refusal keeps its own status and message (the admin typed something + * the server rejected), and success is the body with `available: true` on it. + */ +async function relay(res: Response | string, wrap: (body: Record) => unknown): Promise { + if (typeof res === 'string') return Response.json(unavailable(res)); + + const body = await readBody(res); + if (typeof body === 'string') return Response.json(unavailable(body)); + + if (!res.ok) { + const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`; + return Response.json( + { error, code: typeof body.code === 'string' ? body.code : undefined }, + { status: res.status }, + ); + } + + return Response.json(wrap(body)); +} + +/** `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. */ +async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { + const input = parseCreate(await readJson(ctx.req)); + if (input instanceof Response) return input; + + const res = await callCompanion(creds, { path: '/enroll/invites', method: 'POST', body: input }); + return relay(res, (body) => ({ available: true, invite: body.invite ?? body })); +} + +/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */ +async function list(creds: HeadscaleServerCredentials): Promise { + const res = await callCompanion(creds, { path: '/enroll/invites' }); + return relay(res, (body) => ({ available: true, invites: Array.isArray(body.invites) ? body.invites : [] })); +} + +/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */ +async function revoke(creds: HeadscaleServerCredentials, id: string): Promise { + const res = await callCompanion(creds, { path: `/enroll/invites/${encodeURIComponent(id)}`, method: 'DELETE' }); + return relay(res, (body) => ({ available: true, ...body })); +} + +/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */ +export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise { + const creds = await activeCreds(ctx.userId); + if (creds instanceof Response) return creds; + + const [id, extra] = rest; + if (extra) return notFound(); + + if (!id) { + if (ctx.req.method === 'POST') return create(creds, ctx); + if (ctx.req.method === 'GET') return list(creds); + return methodNotAllowed(); + } + + if (ctx.req.method !== 'DELETE') return methodNotAllowed(); + return revoke(creds, id); +} diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx index 8045dba9..077d1117 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleNav.tsx @@ -1,6 +1,17 @@ import type { LucideIcon } from 'lucide-react'; import { NavLink } from 'react-router'; -import { Network, Server, Laptop, Users, KeyRound, ShieldCheck, Activity, TerminalSquare, Check } from 'lucide-react'; +import { + Network, + Server, + Laptop, + Users, + KeyRound, + Smartphone, + ShieldCheck, + Activity, + TerminalSquare, + Check, +} from 'lucide-react'; import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared'; import { useHeadscaleServers } from './useHeadscaleServers'; @@ -18,6 +29,7 @@ const ICONS: Record = { nodes: Laptop, users: Users, keys: KeyRound, + invites: Smartphone, policy: ShieldCheck, diagnostics: Activity, console: TerminalSquare, diff --git a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx index ecbc237e..f1d35bfe 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx +++ b/src/workspaces/officerdev/src/apps/Headscale/HeadscaleView.tsx @@ -3,6 +3,7 @@ import { ServersView } from './ServersView'; import { NodesView } from './NodesView'; import { UsersView } from './UsersView'; import { KeysView } from './KeysView'; +import { InvitesView } from './InvitesView'; import { PolicyView } from './PolicyView'; import { DiagnosticsView } from './DiagnosticsView'; import { ConsoleView } from './ConsoleView'; @@ -22,6 +23,8 @@ export const HeadscaleView = () => { return ; case 'keys': return ; + case 'invites': + return ; case 'policy': return ; case 'diagnostics': diff --git a/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx new file mode 100644 index 00000000..de8f0507 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/InvitesView.tsx @@ -0,0 +1,396 @@ +import { useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; +import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react'; +import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared'; +import { INVITE_TTL_DEFAULT_SECONDS } from './shared'; +import { useHeadscaleInvites } from './useHeadscaleInvites'; +import { useHeadscaleUsers } from './useHeadscaleData'; +import { headscaleErrorMessage } from './useHeadscaleServers'; +import { fullDate, timeAgo, timeUntil } from './format'; +import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards'; +import { EmptyBody, ViewShell } from './ViewShell'; + +// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5. +// +// The point of the feature is that the person joining does nothing but tap a link and press Join: no app +// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin +// does all of it here and sends one link. +// +// TWO RULES SHAPE THIS FILE. +// +// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display, +// log or store it beyond the moment it is handed to the admin. So the created invite lives in component +// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and +// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another. +// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted +// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole +// reason the credential is not in the URL. + +const STATUS_TONE: Record = { + pending: 'warn', + claimed: 'ok', + expired: 'idle', + revoked: 'bad', +}; + +/** Presets rather than a free number: every one is inside the spec's 60s–24h range by construction. */ +const TTL_OPTIONS = [ + { seconds: 300, label: '5 minutes' }, + { seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' }, + { seconds: 3600, label: '1 hour' }, + { seconds: 86_400, label: '24 hours' }, +] as const; + +const CopyButton = ({ value, label }: { value: string; label: string }) => { + const [done, setDone] = useState(false); + const copy = () => { + void navigator.clipboard?.writeText(value); + setDone(true); + window.setTimeout(() => setDone(false), 1500); + }; + return ( + + ); +}; + +/** + * The QR, rendered client-side into a canvas. + * + * It never leaves the browser — an image endpoint would put the claim token in a request line and therefore + * in a server log, which is the exact thing the fragment-only link format exists to prevent. Error + * correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not + * printed and posted. + */ +const InviteQr = ({ url }: { url: string }) => { + const canvas = useRef(null); + + useEffect(() => { + if (!canvas.current) return; + void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 }); + }, [url]); + + return ; +}; + +type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void }; + +const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { + const [showQr, setShowQr] = useState(true); + + const share = () => { + void navigator + .share?.({ + title: 'Join the tailnet', + text: `Tap to join ${invite.user}'s network`, + url: invite.url, + }) + .catch(() => { + // A cancelled share sheet rejects. Nothing to report — the link is still on screen. + }); + }; + + return ( +
+
+ +
+
Send this link to the device
+

+ It opens OffScale, shows one confirmation screen and joins as{' '} + {invite.user}. Single use, and it stops working{' '} + {timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy — dismiss this and it is gone. +

+
+
+ +
+
+ {invite.url} +
+ + {showQr && ( +
+ +
+ )} + +
+ + {/* Only where the OS actually has a share sheet — a button that silently does nothing is worse + than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */} + {typeof navigator.share === 'function' && ( + + )} + + +
+
+
+ ); +}; + +type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void }; + +const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { + const { users } = useHeadscaleUsers(); + const { create } = useHeadscaleInvites(); + const [user, setUser] = useState(''); + const [note, setNote] = useState(''); + const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS); + const [ephemeral, setEphemeral] = useState(false); + const [tags, setTags] = useState(''); + const [error, setError] = useState(null); + + const submit = async () => { + setError(null); + // The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the + // one this list came from by the time it is claimed. + const chosen = user || users[0]?.name; + if (!chosen) return setError('Create a user first — an invite files the joining device under one.'); + + try { + const invite = await create.mutateAsync({ + user: chosen, + ttlSeconds: ttl, + ephemeral, + note: note.trim(), + tags: tags + .split(/[\s,]+/) + .map((t) => t.trim()) + .filter(Boolean), + }); + onCreated(invite); + onClose(); + } catch (err) { + setError(headscaleErrorMessage(err)); + } + }; + + return ( + +
{ + ev.preventDefault(); + void submit(); + }} + className="flex flex-col gap-3 p-4" + > +
Authorize a new device
+ + + + + + + + + + + + {error && {error}} + +
+ + +
+ +
+ ); +}; + +type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void }; + +const InviteRow = ({ invite, onError }: InviteRowProps) => { + const { revoke } = useHeadscaleInvites(); + const [confirming, setConfirming] = useState(false); + + const run = async () => { + try { + await revoke.mutateAsync(invite.id); + } catch (err) { + onError(headscaleErrorMessage(err)); + } + }; + + return ( + +
+ +
+
+ {invite.note || 'Untitled invite'} + {invite.user} + {invite.ephemeral && ephemeral} + {invite.tags?.map((tag) => ( + + {tag} + + ))} +
+
+ {invite.status} + {invite.status === 'pending' && ( + · expires {timeUntil(invite.expiresAt ?? null)} + )} + {invite.status === 'claimed' && ( + + · claimed {timeAgo(invite.claimedAt ?? null)} + {invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''} + + )} + · created {timeAgo(invite.createdAt ?? null)} +
+
+ + {/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in + Nodes, and conflating the two here would make "revoke" mean two different things. */} + {invite.status === 'pending' && ( +
+ {confirming ? ( + <> + + + + ) : ( + + )} +
+ )} +
+
+ ); +}; + +export const InvitesView = () => { + const { invites, unavailable, isLoading, error } = useHeadscaleInvites(); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(null); + const [actionError, setActionError] = useState(null); + + const pending = invites.filter((i) => i.status === 'pending').length; + + return ( + +
+
+
+

Device invites

+

+ {unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`} +

+
+ {!creating && !unavailable && ( + + )} +
+ + {/* Most registered servers have no enrolment API, and that is a normal state — the rest of the + Headscale sections work regardless, so this must not read as a broken screen. */} + {unavailable && ( + } + title="This server cannot mint invites" + hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`} + /> + )} + + {created && setCreated(null)} />} + {creating && setCreating(false)} />} + {actionError && {actionError}} + + {!unavailable && invites.length === 0 && !creating && ( + } + title="No invites yet" + hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure." + /> + )} + + {invites.map((invite) => ( + + ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Headscale/shared.ts b/src/workspaces/officerdev/src/apps/Headscale/shared.ts index fdbe3775..fab45e72 100644 --- a/src/workspaces/officerdev/src/apps/Headscale/shared.ts +++ b/src/workspaces/officerdev/src/apps/Headscale/shared.ts @@ -9,6 +9,7 @@ export const HEADSCALE_SECTIONS = [ { id: 'nodes', label: 'Nodes' }, { id: 'users', label: 'Users' }, { id: 'keys', label: 'Pre-auth keys' }, + { id: 'invites', label: 'Device invites' }, { id: 'policy', label: 'Access policy' }, { id: 'diagnostics', label: 'Diagnostics' }, { id: 'console', label: 'Console' }, @@ -179,3 +180,46 @@ export type HeadscalePreAuthKey = { /** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */ export const NO_ACTIVE_SERVER = 'no_active_server'; + +// ── Device invites ──────────────────────────────────────────────────────────────────────────────── +// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is +// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract: +// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer. + +export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked'; + +/** What the admin list returns. It carries no claim token and no key — by design, at every status. */ +export type HeadscaleInvite = { + id: string; + user: string; + note?: string | null; + status: InviteStatus; + ephemeral?: boolean; + tags?: string[]; + createdAt?: string | null; + expiresAt?: string | null; + claimedAt?: string | null; + claimedFromIp?: string | null; +}; + +/** + * The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in + * component state, shown once, and never written to a cache, a query key or a log. + */ +export type HeadscaleInviteCreated = HeadscaleInvite & { url: string }; + +export type InviteCreateInput = { + user: string; + ttlSeconds: number; + ephemeral: boolean; + tags: string[]; + note: string; +}; + +export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable; +export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable; + +/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */ +export const INVITE_TTL_MIN_SECONDS = 60; +export const INVITE_TTL_DEFAULT_SECONDS = 900; +export const INVITE_TTL_MAX_SECONDS = 86_400; diff --git a/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts new file mode 100644 index 00000000..2d4e147b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Headscale/useHeadscaleInvites.ts @@ -0,0 +1,59 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared'; + +// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the +// server's own companion — see src/servers/sidecar/headscale/invites.ts for why the records live there and +// not here. +// +// The create result is deliberately NOT merged into the list cache. It is the one response that contains the +// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and +// drops it. The list is refetched instead, which returns the same invite without its token. + +const BASE = '/headscale/_officer/enroll/invites'; +const INVITES_KEY = ['headscale', 'invites'] as const; + +const EMPTY: InvitesListResult = { available: true, invites: [] }; + +export function useHeadscaleInvites() { + const { get, post, delete: del } = useClient(); + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY }); + + const query = useQuery({ + queryKey: INVITES_KEY, + queryFn: () => get(BASE), + // A pending invite expires on a clock, so a list left open goes wrong on its own. Cheap: one companion + // call against a table with a handful of rows. + refetchInterval: 30_000, + staleTime: 10_000, + }); + + const create = useMutation({ + mutationFn: async (input: InviteCreateInput): Promise => { + const result = await post(BASE, input); + // An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the + // form shows it where the admin is looking rather than rendering an empty link panel. + if (!result.available) throw new Error(result.reason); + return result.invite; + }, + onSuccess: invalidate, + }); + + const revoke = useMutation({ + mutationFn: (id: string) => del(`${BASE}/${encodeURIComponent(id)}`), + onSuccess: invalidate, + }); + + const result = query.data ?? EMPTY; + + return { + invites: result.available ? result.invites : [], + /** Set when this server has no enrolment API — a state to explain, not an error. */ + unavailable: result.available ? null : result.reason, + isLoading: query.isLoading, + error: query.error, + create, + revoke, + }; +}