import { useEffect, useRef, useState } from 'react'; import QRCode from 'qrcode'; import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react'; import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared'; import { INVITE_TTL_DEFAULT_SECONDS } from './shared'; import { useHeadscaleInvites } from './useHeadscaleInvites'; import { useHeadscaleUsers } from './useHeadscaleData'; import { headscaleErrorMessage } from './useHeadscaleServers'; import { fullDate, timeAgo, timeUntil } from './format'; import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards'; import { EmptyBody, ViewShell } from './ViewShell'; import { copyToClipboard } from 'helpers/clipboard'; // Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5. // // The point of the feature is that the person joining does nothing but tap a link and press Join: no app // store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin // does all of it here and sends one link. // // TWO RULES SHAPE THIS FILE. // // 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display, // log or store it beyond the moment it is handed to the admin. So the created invite lives in component // state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and // the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another. // 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted // by the server at claim time. A leaked link before it is claimed is revocable, which is the whole // reason the credential is not in the URL. const STATUS_TONE: Record = { pending: 'warn', claimed: 'ok', expired: 'idle', revoked: 'bad', }; /** Presets rather than a free number: every one is inside the spec's 60s–24h range by construction. */ const TTL_OPTIONS = [ { seconds: 300, label: '5 minutes' }, { seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' }, { seconds: 3600, label: '1 hour' }, { seconds: 86_400, label: '24 hours' }, ] 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 ( ); }; /** * The QR, rendered client-side into a canvas. * * It never leaves the browser — an image endpoint would put the claim token in a request line and therefore * in a server log, which is the exact thing the fragment-only link format exists to prevent. Error * correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not * printed and posted. */ const InviteQr = ({ url }: { url: string }) => { const canvas = useRef(null); useEffect(() => { if (!canvas.current) return; void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 }); }, [url]); return ; }; type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void }; const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { const [showQr, setShowQr] = useState(true); // Plain https now — the link lands on a page the companion serves, which bounces into the app. It goes in // `url` rather than `text` so share targets treat it as a link and preserve the fragment. A cancelled sheet // rejects — nothing to report there, the link is still on screen. const share = () => { void navigator .share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}`, url: invite.url }) .catch(() => {}); }; return (
Send this link to the device

It opens OffScale, shows one confirmation screen and joins as{' '} {invite.user}. Single use, and it stops working{' '} {timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy — dismiss this and it is gone.

{invite.url}
{showQr && (
)}
{/* Only where the OS actually has a share sheet — a button that silently does nothing is worse than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */} {typeof navigator.share === 'function' && ( )}
); }; type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void }; const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { const { users } = useHeadscaleUsers(); const { create } = useHeadscaleInvites(); const [user, setUser] = useState(''); const [note, setNote] = useState(''); const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS); const [ephemeral, setEphemeral] = useState(false); const [tags, setTags] = useState(''); const [error, setError] = useState(null); const submit = async () => { setError(null); // The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the // one this list came from by the time it is claimed. const chosen = user || users[0]?.name; if (!chosen) return setError('Create a user first — an invite files the joining device under one.'); try { const invite = await create.mutateAsync({ user: chosen, ttlSeconds: ttl, ephemeral, note: note.trim(), tags: tags .split(/[\s,]+/) .map((t) => t.trim()) .filter(Boolean), }); onCreated(invite); onClose(); } catch (err) { setError(headscaleErrorMessage(err)); } }; return (
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 p-4" >
Authorize a new device
{error && {error}}
); }; type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void }; const InviteRow = ({ invite, onError }: InviteRowProps) => { const { revoke } = useHeadscaleInvites(); const [confirming, setConfirming] = useState(false); const run = async () => { try { await revoke.mutateAsync(invite.id); } catch (err) { onError(headscaleErrorMessage(err)); } }; return (
{invite.note || 'Untitled invite'} {invite.user} {invite.ephemeral && ephemeral} {invite.tags?.map((tag) => ( {tag} ))}
{invite.status} {invite.status === 'pending' && ( · expires {timeUntil(invite.expiresAt ?? null)} )} {invite.status === 'claimed' && ( · claimed {timeAgo(invite.claimedAt ?? null)} {invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''} )} · created {timeAgo(invite.createdAt ?? null)}
{/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in Nodes, and conflating the two here would make "revoke" mean two different things. */} {invite.status === 'pending' && (
{confirming ? ( <> ) : ( )}
)}
); }; export const InvitesView = () => { const { invites, unavailable, isLoading, error } = useHeadscaleInvites(); const [creating, setCreating] = useState(false); const [created, setCreated] = useState(null); const [actionError, setActionError] = useState(null); const pending = invites.filter((i) => i.status === 'pending').length; return (

Device invites

{unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`}

{!creating && !unavailable && ( )}
{/* Most registered servers have no enrolment API, and that is a normal state — the rest of the Headscale sections work regardless, so this must not read as a broken screen. */} {unavailable && ( } title="This server cannot mint invites" hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`} /> )} {created && setCreated(null)} />} {creating && setCreating(false)} />} {actionError && {actionError}} {!unavailable && invites.length === 0 && !creating && ( } title="No invites yet" hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure." /> )} {invites.map((invite) => ( ))}
); };