import { useEffect, useRef, useState } from 'react'; import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react'; import { timeAgo } from './format'; import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy'; import { PolicyAssistant } from './PolicyAssistant'; import { Card, SectionHeader, Button, ErrorNote } from './Cards'; import { ViewShell } from './ViewShell'; // The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the // hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that // reformats or a client-side parser that disagrees with Headscale would both destroy more than they add. // // It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach // which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a // way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what // this file should look like" is the actual reason this screen was hard to use. // // Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and // host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and // shows the verdict verbatim — including the line and column, which is the whole reason to show it at all. // // Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays // inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so // at the top, permanently, because the owner needs to go and edit a file on the server instead. /** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */ function useSaveShortcut(onSave: () => void, enabled: boolean) { const handler = useRef(onSave); handler.current = onSave; useEffect(() => { if (!enabled) return; const onKeyDown = (ev: KeyboardEvent) => { if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') { ev.preventDefault(); handler.current(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [enabled]); } const ReadOnlyBanner = ({ message }: { message: string }) => (
This server's policy is read-only

Headscale said: {message}

It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one way in) and reload it there. Everything below is still the live document, and still readable.

); const Rejected = ({ message }: { message: string }) => (
Headscale rejected this policy
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
{message}

Nothing was saved — the tailnet is still running the previous policy.

); export const PolicyView = () => { const { policy, isLoading, error, save } = useHeadscalePolicy(); const [draft, setDraft] = useState(null); const [editing, setEditing] = useState(false); const [failure, setFailure] = useState(null); // Sticky for the session: once a server has refused a write, every later save would refuse identically, // and re-discovering that by pressing save again is not information. const [readOnly, setReadOnly] = useState(null); const [savedAt, setSavedAt] = useState(null); // The fetched document seeds the editor once. After that the draft owns the text — a refetch must never // reach in and replace what someone is typing. const text = draft ?? policy?.policy ?? ''; const dirty = draft !== null && draft !== (policy?.policy ?? ''); const submit = async () => { if (!dirty || readOnly || save.isPending) return; setFailure(null); try { await save.mutateAsync(text); setDraft(null); setSavedAt(Date.now()); // A clean save is the end of the edit, not the start of the next one — back to reading. setEditing(false); } catch (err) { const parsed = policySaveFailure(err); setFailure(parsed); if (parsed.kind === 'readOnly') setReadOnly(parsed.message); } }; useSaveShortcut(() => void submit(), editing && dirty && !readOnly); const revert = () => { setDraft(null); setFailure(null); }; /** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */ const stopEditing = () => { revert(); setEditing(false); }; return (
) : ( ) } /> {readOnly && } {failure?.kind === 'rejected' && } {failure?.kind === 'unknown' && {failure.message}} {editing && ( { setDraft(proposed); setFailure(null); setSavedAt(null); }} disabled={save.isPending} /> )}