Files
offscale/web/useHeadscaleInvites.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00

60 lines
2.4 KiB
TypeScript

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<InvitesListResult>(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<HeadscaleInviteCreated> => {
const result = await post<InviteCreateResult>(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,
};
}