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 = '/offscale/_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, }; }