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'; import { copyToClipboard } from 'helpers/clipboard'; // 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 copyToClipboard(value); setDone(true); window.setTimeout(() => setDone(false), 1500); }; return ( ); }; type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void }; const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => { const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`; return (
Copy this key now

Headscale stores it hashed. Once you dismiss this, nothing — not Officer, not the server — can show it again.

Key
{secret}
Join command
{command}
); }; type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string }; const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => ( ); 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(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 (
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 p-4" >
New pre-auth key
{error && {error}}
); }; 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) => { try { await fn(); } catch (err) { onError(headscaleErrorMessage(err)); } }; return (
{entry.keyDisplay} {entry.user && {entry.user.name}} {entry.reusable && reusable} {entry.ephemeral && ephemeral} {entry.aclTags.map((tag) => ( {tag} ))}
{entry.status} · expires {timeUntil(entry.expiration)} · created {timeAgo(entry.createdAt)}
{entry.status === 'active' && ( )} {confirming ? ( <> ) : ( )}
); }; export const KeysView = () => { const { keys, isLoading, error } = useHeadscaleKeys(); const { active } = useHeadscaleServers(); const [creating, setCreating] = useState(false); const [secret, setSecret] = useState(null); const [filter, setFilter] = useState('active'); const [actionError, setActionError] = useState(null); const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active'); const activeCount = keys.filter((k) => k.status === 'active').length; return (

Pre-auth keys

{activeCount} active of {keys.length}

{STATUS_FILTERS.map((option) => ( ))}
{!creating && ( )}
{secret && setSecret(null)} />} {creating && setCreating(false)} />} {actionError && {actionError}} {keys.length === 0 && !creating && ( } 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 && (
No active keys. Switch to “All” to see spent and expired ones.
)} {visible.map((entry) => ( ))}
); };