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>
60 lines
2.4 KiB
TypeScript
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,
|
|
};
|
|
}
|