Files
offscale/web/PolicyView.tsx
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

211 lines
9.3 KiB
TypeScript

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 }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
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.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [failure, setFailure] = useState<PolicySaveFailure | null>(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<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(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 (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
editing ? (
<div className="flex items-center gap-2">
<Button onClick={stopEditing} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
{dirty ? 'Discard' : 'Done'}
</Button>
<Button
variant="primary"
onClick={() => void submit()}
disabled={!dirty || !!readOnly || save.isPending}
>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
) : (
<Button
onClick={() => setEditing(true)}
disabled={!!readOnly}
title={readOnly ? 'This server will not accept written policies' : undefined}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
{editing && (
<PolicyAssistant
policy={text}
onApply={(proposed) => {
setDraft(proposed);
setFailure(null);
setSavedAt(null);
}}
disabled={save.isPending}
/>
)}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
readOnly={!editing}
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
}`}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
Headscale checks that the document is valid, not that it is what you meant.
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
</p>
</div>
</ViewShell>
);
};