headscale nodes, users and pre-auth key management

domain routes in the sidecar: nodes with per-route approval done as a
read-modify-write (headscale's approve_routes replaces the whole set),
users enriched with node counts, and pre-auth keys.

pre-auth key secrets are revealed by call path, not by inspecting the
value. headscale masks keys created since 0.28, but returns older
plaintext ones in full from the list endpoint for backwards
compatibility, so listing would otherwise ship live secrets into the
browser's query cache. the list always nulls the secret; only creation
reveals it, and the ui shows it once with a copy affordance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:08:02 +00:00
co-authored by Claude Opus 5
parent adf922de30
commit 0d227c46a3
16 changed files with 1769 additions and 23 deletions
@@ -1,31 +1,26 @@
import { Construction } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { HEADSCALE_SECTION_CHANNEL, HEADSCALE_SECTIONS, type HeadscaleSectionId } from './shared';
import { HEADSCALE_SECTION_CHANNEL, type HeadscaleSectionId } from './shared';
import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
// Right panel of the /headscale workspace — renders the section the nav selected.
//
// Only `servers` is implemented. Nodes, users and pre-auth keys need the sidecar's domain routes, which
// don't exist yet; they say so plainly rather than rendering an empty table that looks like a broken fetch.
const Placeholder = ({ id }: { id: HeadscaleSectionId }) => {
const label = HEADSCALE_SECTIONS.find((s) => s.id === id)?.label ?? id;
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Construction className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold">{label}</div>
<div className="text-sm text-muted-foreground">Not built yet</div>
</div>
</div>
);
};
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => {
const [section] = usePanelChannel<HeadscaleSectionId>(HEADSCALE_SECTION_CHANNEL, 'servers');
if (section === 'servers') return <ServersView />;
return <Placeholder id={section} />;
switch (section) {
case 'nodes':
return <NodesView />;
case 'users':
return <UsersView />;
case 'keys':
return <KeysView />;
default:
return <ServersView />;
}
};
@@ -0,0 +1,340 @@
import { useState } from 'react';
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
import type { HeadscalePreAuthKey } from './shared';
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// Pre-auth keys — the tokens a machine presents to join the tailnet.
//
// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the
// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the
// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy
// and the ready-to-paste join command, and only clears on an explicit dismiss.
//
// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones.
const STATUS_FILTERS = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
] as const;
type StatusFilter = (typeof STATUS_FILTERS)[number]['id'];
const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
return (
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
Headscale stores it hashed. Once you dismiss this, nothing not Officer, not the server can show it
again.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
{secret}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
{command}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={secret} label="Copy key" />
<CopyButton value={command} label="Copy command" />
<Button variant="danger" onClick={onDismiss}>
I've saved it
</Button>
</div>
</div>
</div>
);
};
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={(ev) => onChange(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">{label}</span>
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
</span>
</label>
);
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleKeys();
const [userId, setUserId] = useState('');
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [days, setDays] = useState('90');
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
const chosen = userId || users[0]?.id;
if (!chosen) return setError('Create a user first every key belongs to one.');
const expirationDays = Number(days);
if (!Number.isFinite(expirationDays) || expirationDays <= 0)
return setError('Expiry must be a positive number of days');
try {
const result = await create.mutateAsync({
userId: chosen,
reusable,
ephemeral,
expirationDays,
aclTags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
});
if (result.key.key) onCreated(result.key.key);
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New pre-auth key</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={userId || users[0]?.id || ''}
onChange={(ev) => setUserId(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2">
<Toggle
checked={reusable}
onChange={setReusable}
label="Reusable"
hint="Any number of machines can join with it, until it expires."
/>
<Toggle
checked={ephemeral}
onChange={setEphemeral}
label="Ephemeral"
hint="Nodes that join with it are removed when they go offline. For containers and CI."
/>
</div>
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="server, ci"
hint="Comma separated. The tag: prefix is added for you."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create key
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void };
const KeyRow = ({ entry, onError }: KeyRowProps) => {
const { expire, remove } = useHeadscaleKeys();
const [confirming, setConfirming] = useState(false);
const busy = expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[entry.status]} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
{entry.user && <Badge>{entry.user.name}</Badge>}
{entry.reusable && <Badge>reusable</Badge>}
{entry.ephemeral && <Badge>ephemeral</Badge>}
{entry.aclTags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{entry.status}</span>
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
<span>· created {timeAgo(entry.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{entry.status === 'active' && (
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
<TimerOff className="h-3.5 w-3.5" />
Expire
</Button>
)}
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm delete
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
export const KeysView = () => {
const { keys, isLoading, error } = useHeadscaleKeys();
const { active } = useHeadscaleServers();
const [creating, setCreating] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>('active');
const [actionError, setActionError] = useState<string | null>(null);
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
const activeCount = keys.filter((k) => k.status === 'active').length;
return (
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{activeCount} active of {keys.length}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
{STATUS_FILTERS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{option.label}
</button>
))}
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New key
</Button>
)}
</div>
</div>
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{keys.length === 0 && !creating && (
<EmptyBody
icon={<KeyRound className="h-6 w-6" />}
title="No pre-auth keys"
hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you."
/>
)}
{keys.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">
No active keys. Switch to All to see spent and expired ones.
</div>
)}
{visible.map((entry) => (
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -0,0 +1,286 @@
import { useState } from 'react';
import { Laptop, Globe, Trash2, Pencil, TimerReset, Check, X, Search, Copy, ChevronRight } from 'lucide-react';
import type { HeadscaleNode } from './shared';
import { useHeadscaleNodes } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The nodes section — the machines in the tailnet.
//
// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node
// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
const copy = (text: string) => void navigator.clipboard?.writeText(text);
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => {
const isExit = route === '0.0.0.0/0' || route === '::/0';
return (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
<button
type="button"
disabled={busy}
onClick={() => onToggle(!approved)}
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
approved
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
}`}
>
{approved ? 'Approved' : 'Approve'}
</button>
</div>
);
};
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
const NodeCard = ({ node, onError }: NodeCardProps) => {
const { rename, toggleRoute, expire, remove } = useHeadscaleNodes();
const [open, setOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(node.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draftName.trim();
setRenaming(false);
if (!name || name === node.name) return;
await run(() => rename.mutateAsync({ id: node.id, name }));
};
return (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2.5 px-3.5 py-3">
<Dot tone={node.online ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(ev) => setDraftName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button
type="button"
onClick={() => void submitRename()}
className="cursor-pointer p-1 text-emerald-400"
>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{node.name}</span>
{node.user && <Badge>{node.user.name}</Badge>}
{node.isExitNode && <Badge>exit</Badge>}
{node.tags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
</div>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
>
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
</div>
{open && (
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="text-zinc-500">Addresses</div>
<div className="flex flex-col gap-0.5">
{node.ipAddresses.map((ip) => (
<button
key={ip}
type="button"
onClick={() => copy(ip)}
title="Copy"
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
>
{ip}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
))}
</div>
<div className="text-zinc-500">Hostname</div>
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
<div className="text-zinc-500">Registered</div>
<div className="text-zinc-300">
{timeAgo(node.createdAt)} · {node.registerMethod}
</div>
<div className="text-zinc-500">Key expires</div>
<div className="text-zinc-300" title={fullDate(node.expiry)}>
{timeUntil(node.expiry)}
</div>
<div className="text-zinc-500">Last seen</div>
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
{node.online ? 'now' : timeAgo(node.lastSeen)}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Advertised routes
</div>
{node.availableRoutes.length === 0 ? (
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
) : (
<div className="flex flex-col gap-1">
{node.availableRoutes.map((route) => (
<RouteRow
key={route}
route={route}
approved={node.approvedRoutes.includes(route)}
busy={busy}
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
/>
))}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraftName(node.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
<Button
onClick={() => void run(() => expire.mutateAsync(node.id))}
disabled={busy}
title="Expire the node's key — it stays registered but must re-authenticate"
>
<TimerReset className="h-3.5 w-3.5" />
Force re-auth
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
)}
</div>
</Card>
);
};
export const NodesView = () => {
const { nodes, isLoading, error } = useHeadscaleNodes();
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const needle = filter.trim().toLowerCase();
const visible = needle
? nodes.filter(
(n) =>
n.name.toLowerCase().includes(needle) ||
n.hostname.toLowerCase().includes(needle) ||
n.user?.name.toLowerCase().includes(needle) ||
n.ipAddresses.some((ip) => ip.includes(needle)) ||
n.tags.some((t) => t.toLowerCase().includes(needle)),
)
: nodes;
const online = nodes.filter((n) => n.online).length;
return (
<ViewShell isLoading={isLoading} error={error} label="nodes">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div className="flex items-center gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{nodes.length} registered · {online} online
</p>
</div>
<div className="relative w-56 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
<input
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter by name, user, IP, tag"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50"
/>
</div>
</div>
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{nodes.length === 0 && (
<EmptyBody
icon={<Laptop className="h-6 w-6" />}
title="No nodes yet"
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
/>
)}
{nodes.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches {filter}.</div>
)}
{visible.map((node) => (
<NodeCard key={node.id} node={node} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
import type { HeadscaleUserWithCounts } from './shared';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo } from './format';
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
//
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
const UserRow = ({ user, onError }: UserRowProps) => {
const { rename, remove } = useHeadscaleUsers();
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(user.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draft.trim();
setRenaming(false);
if (!name || name === user.name) return;
await run(() => rename.mutateAsync({ id: user.id, name }));
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
if (!name.trim()) return setError('A name is required');
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react';
import { Loader2, ServerOff } from 'lucide-react';
import { NO_ACTIVE_SERVER } from './shared';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { ErrorNote } from './Cards';
// The loading / no-server / failed states every domain section shares.
//
// "No active server" is a 409 carrying a `code`, deliberately not a 404 and deliberately not an empty
// list — an empty node table would read as "your tailnet is empty", which is a very different and much
// more alarming statement than "you haven't picked a server".
function isNoActiveServer(err: unknown): boolean {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string') return false;
try {
return (JSON.parse(raw) as { code?: unknown }).code === NO_ACTIVE_SERVER;
} catch {
return false;
}
}
type ViewShellProps = {
isLoading: boolean;
error: unknown;
/** What this section is called, for the loading and empty copy. */
label: string;
children: ReactNode;
};
export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps) => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading {label}
</div>
);
}
if (error && isNoActiveServer(error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<ServerOff className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to see its {label}.</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not load {label}: {headscaleErrorMessage(error)}
</ErrorNote>
</div>
);
}
return <div className="h-full overflow-y-auto p-4">{children}</div>;
};
/** Centred "nothing here yet" body for a section whose fetch succeeded but returned nothing. */
export const EmptyBody = ({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) => (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">{icon}</div>
<div>
<div className="text-base font-semibold text-zinc-100">{title}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">{hint}</p>
</div>
</div>
);
@@ -0,0 +1,35 @@
// Date formatting for the Headscale views. The sidecar already turned protobuf's zero timestamp into null,
// so null genuinely means "never" here and every helper says so rather than printing a fake date.
export function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return 'just now';
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 365) return `${days}d ago`;
return `${Math.round(days / 365)}y ago`;
}
/** "in 3d" / "5h ago" — signed, for expiry dates that may be either side of now. */
export function timeUntil(iso: string | null): string {
if (!iso) return 'never';
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 0) return timeAgo(iso);
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `in ${Math.max(1, minutes)}m`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `in ${hours}h`;
return `in ${Math.round(hours / 24)}d`;
}
export function fullDate(iso: string | null): string {
if (!iso) return 'never';
return new Date(iso).toLocaleString();
}
@@ -40,3 +40,59 @@ export type HeadscaleHealth = {
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
export type HeadscaleUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
export type HeadscaleNode = {
id: string;
name: string;
hostname: string;
user: HeadscaleUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** Null means the node's key never expires. */
expiry: string | null;
createdAt: string | null;
registerMethod: string;
tags: string[];
/** What the node advertises. */
availableRoutes: string[];
/** What the admin has approved — the writable set. */
approvedRoutes: string[];
/** What is actually in effect. */
subnetRoutes: string[];
isExitNode: boolean;
};
export type HeadscalePreAuthKey = {
id: string;
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
key: string | null;
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: HeadscaleUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
status: 'active' | 'used' | 'expired';
};
/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';
@@ -0,0 +1,158 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared';
// Queries for the domain sections. All three act on whichever server is active, so they live under the
// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers).
//
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
// are small and the correctness is worth more than the round trip.
const NODES_KEY = ['headscale', 'nodes'] as const;
const USERS_KEY = ['headscale', 'users'] as const;
const KEYS_KEY = ['headscale', 'keys'] as const;
const EMPTY_NODES: HeadscaleNode[] = [];
const EMPTY_USERS: HeadscaleUserWithCounts[] = [];
const EMPTY_KEYS: HeadscalePreAuthKey[] = [];
export function useHeadscaleNodes() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: NODES_KEY,
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
refetchInterval: 20_000,
staleTime: 10_000,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
onSuccess: invalidate,
});
const setTags = useMutation({
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
onSuccess: invalidate,
});
// Single-route toggle: the sidecar reads the current approved set and writes it back with one change,
// because Headscale's approve_routes replaces the whole set.
const toggleRoute = useMutation({
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
onSuccess: invalidate,
});
return {
nodes: query.data?.nodes ?? EMPTY_NODES,
isLoading: query.isLoading,
error: query.error,
rename,
setTags,
toggleRoute,
expire,
remove,
};
}
export function useHeadscaleUsers() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
const query = useQuery({
queryKey: USERS_KEY,
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
post('/headscale/_officer/users', input),
onSuccess: invalidate,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/users/${id}/rename`, { name }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
onSuccess: invalidate,
});
return {
users: query.data?.users ?? EMPTY_USERS,
isLoading: query.isLoading,
error: query.error,
create,
rename,
remove,
};
}
export type CreateKeyInput = {
userId: string;
reusable: boolean;
ephemeral: boolean;
expirationDays: number;
aclTags: string[];
};
export function useHeadscaleKeys() {
const { get, post, delete: del } = useClient();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
const query = useQuery({
queryKey: KEYS_KEY,
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
staleTime: 30_000,
});
// The response carries the only copy of the secret that will ever exist. It is returned to the caller
// (not merged into the list cache) so the view can show it once and deliberately drop it.
const create = useMutation({
mutationFn: (input: CreateKeyInput) =>
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
onSuccess: invalidate,
});
return {
keys: query.data?.keys ?? EMPTY_KEYS,
isLoading: query.isLoading,
error: query.error,
create,
expire,
remove,
};
}