import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; import type { HeadscalePolicy } from './shared'; import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared'; // The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely // in how a failed save is classified, because the two failures need opposite reactions from the owner: // "your document is wrong, here is where" versus "this server does not accept written policies at all". const POLICY_KEY = ['headscale', 'policy'] as const; const PATH = '/offscale/_officer/policy'; /** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */ export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string }; /** useClient throws `{status, message}` with the raw body text — dig the sidecar's `{error, code}` out. */ export function policySaveFailure(err: unknown): PolicySaveFailure { const raw = (err as { message?: unknown } | null)?.message; if (typeof raw !== 'string' || !raw) return { kind: 'unknown', message: 'The policy could not be saved' }; try { const parsed = JSON.parse(raw) as { error?: unknown; code?: unknown }; const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The policy could not be saved'; if (parsed.code === POLICY_READ_ONLY) return { kind: 'readOnly', message }; if (parsed.code === POLICY_REJECTED) return { kind: 'rejected', message }; return { kind: 'unknown', message }; } catch { return { kind: 'unknown', message: raw.slice(0, 300) }; } } /** What the assistant proposes. Never saved by the hook — PolicyView puts it in the draft. */ export type PolicyProposal = { explanation: string; policy: string }; /** The assistant's failures are all one sentence to the owner; only the sidecar's `error` field is useful. */ export function assistFailure(err: unknown): string { const raw = (err as { message?: unknown } | null)?.message; if (typeof raw !== 'string' || !raw) return 'The assistant could not be reached'; try { const parsed = JSON.parse(raw) as { error?: unknown }; return typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The assistant could not be reached'; } catch { return raw.slice(0, 300); } } /** * Ask for a revised policy in English. Separate from `useHeadscalePolicy` because it is a different kind of * thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload. */ export function useHeadscalePolicyAssist() { const { post } = useClient(); return useMutation({ mutationFn: (input: { prompt: string; policy: string }) => post(`${PATH}/assist`, input), }); } export function useHeadscalePolicy() { const { get, put } = useClient(); const qc = useQueryClient(); const query = useQuery({ queryKey: POLICY_KEY, queryFn: () => get(PATH), // No polling and a long staleTime: this is a document someone is editing. A background refetch that // replaced the textarea under a half-written rule would be the worst thing this screen could do. staleTime: 5 * 60_000, refetchOnWindowFocus: false, }); const save = useMutation({ mutationFn: (policy: string) => put(PATH, { policy }), // Seed the cache from the response rather than invalidating: a refetch here would race the editor's // own state and could show the pre-save document for a frame. onSuccess: (data) => qc.setQueryData(POLICY_KEY, data), }); return { policy: query.data ?? null, isLoading: query.isLoading, error: query.error, refetch: query.refetch, save }; }