WireGuard server with an embedded admin console

Go backend that drives kernel WireGuard over netlink (wireguard-go as the
fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls,
SQLite for peers, users, sessions, traffic history and the audit log.

React console: dashboard with live rates and usage history, peer management
with QR codes and .conf downloads, disconnect, session reset, key rotation,
expiry, client-supplied keys, settings, users with admin and viewer roles,
two-factor authentication with recovery codes, audit log.

Docker image on Alpine with compose files for bridged and host networking,
CI and GHCR publish workflows, performance notes.
This commit is contained in:
jcoffey
2026-09-12 19:56:08 -07:00
commit 6c006e1d4d
72 changed files with 11675 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react";
import { useAuth, useLive } from "../state";
const items = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/peers", label: "Peers", icon: Activity },
{ href: "/settings", label: "Settings", icon: Settings },
{ href: "/users", label: "Users", icon: Users },
{ href: "/audit", label: "Audit log", icon: ClipboardList },
{ href: "/account", label: "Account", icon: Shield },
];
export function Layout({ children }: { children: ReactNode }) {
const [location] = useLocation();
const { me, signOut } = useAuth();
const { connected } = useLive();
return (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 32 32">
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>
<div className="brand-name">WGX</div>
<div className="brand-sub">WireGuard server</div>
</div>
</div>
<nav className="nav">
{items.map((it) => {
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
const Icon = it.icon;
return (
<Link key={it.href} href={it.href} className={active ? "active" : ""}>
<Icon />
<span>{it.label}</span>
</Link>
);
})}
</nav>
<div className="sidebar-foot">
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
</div>
<div className="nowrap">
{me?.username} <span className="faint">({me?.role})</span>
</div>
<button className="btn sm ghost" onClick={() => void signOut()} style={{ alignSelf: "flex-start", marginLeft: -6 }}>
<LogOut /> Sign out
</button>
<a className="nowrap" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
AGPL-3.0 source
</a>
</div>
</aside>
<main className="main">{children}</main>
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
import { Copy, Download, KeyRound, Pencil, Power, RefreshCw, Trash2 } from "lucide-react";
import { api, type Live, type Peer, type Range, type Settings, type TrafficPoint } from "../api";
import { ago, bytes, dateTime, duration, rate, shortKey } from "../format";
import { errorMessage, useNow, useToast } from "../state";
import { Legend, TrafficChart } from "./charts";
import { Confirm, Modal, Segmented, copyText } from "./ui";
import { PeerForm } from "./PeerForm";
const rangeMs: Record<Range, number> = { "1h": 3600e3, "24h": 86400e3, "7d": 7 * 86400e3, "30d": 30 * 86400e3 };
export function PeerDetail({ peer, live, settings, isAdmin, onClose, onChanged, initialTab = "overview", initialConfig }: { peer: Peer; live: Live; settings: Settings; isAdmin: boolean; onClose: () => void; onChanged: (p?: Peer) => void; initialTab?: "overview" | "config"; initialConfig?: string }) {
const toast = useToast();
const now = useNow(1000);
const [tab, setTab] = useState<"overview" | "config">(initialTab);
const [config, setConfig] = useState<string>(initialConfig ?? "");
const [range, setRange] = useState<Range>("24h");
const [series, setSeries] = useState<TrafficPoint[]>([]);
const [editing, setEditing] = useState(false);
const [confirm, setConfirm] = useState<null | "delete" | "rotate" | "disable">(null);
const [busy, setBusy] = useState(false);
const [qrKey, setQrKey] = useState(0);
useEffect(() => {
if (tab === "config" && !config) api.peerConfig(peer.id).then(setConfig).catch((e) => toast(errorMessage(e), "bad"));
}, [tab, config, peer.id, toast]);
useEffect(() => {
api.peerUsage(peer.id, range).then(setSeries).catch(() => {});
}, [peer.id, range, peer.updatedAt]);
async function act(fn: () => Promise<unknown>, done: string) {
setBusy(true);
try {
await fn();
toast(done);
onChanged();
} catch (e) {
toast(errorMessage(e), "bad");
} finally {
setBusy(false);
setConfirm(null);
}
}
const state = !peer.enabled ? "disabled" : peer.expired ? "expired" : live.connected ? "on" : "off";
const stateLabel = { disabled: "Disabled", expired: "Expired", on: "Connected", off: "Not connected" }[state];
return (
<>
<Modal
title={peer.name}
onClose={onClose}
wide
footer={
isAdmin ? (
<div className="btn-row" style={{ justifyContent: "flex-end", width: "100%" }}>
<button className="btn sm" onClick={() => setEditing(true)} disabled={busy}>
<Pencil /> Edit
</button>
{peer.enabled ? (
<button className="btn sm" onClick={() => setConfirm("disable")} disabled={busy} title="Remove from the interface; drops the session">
<Power /> Disconnect
</button>
) : (
<button className="btn sm" onClick={() => act(() => api.enablePeer(peer.id), "Peer enabled")} disabled={busy}>
<Power /> Enable
</button>
)}
<button className="btn sm" onClick={() => act(() => api.resetPeer(peer.id), "Session reset")} disabled={busy || !peer.enabled} title="Drop the current session; a client that is sending traffic handshakes again within about 15 seconds">
<RefreshCw /> Reset session
</button>
{peer.serverKeys && (
<button className="btn sm" onClick={() => setConfirm("rotate")} disabled={busy} title="New key pair; the old config stops working">
<KeyRound /> Rotate keys
</button>
)}
<button className="btn sm danger" onClick={() => setConfirm("delete")} disabled={busy}>
<Trash2 /> Delete
</button>
</div>
) : undefined
}
>
<div className="tabs">
<button className={tab === "overview" ? "active" : ""} onClick={() => setTab("overview")}>
Overview
</button>
<button className={tab === "config" ? "active" : ""} onClick={() => setTab("config")}>
Configuration
</button>
</div>
{tab === "overview" && (
<>
<div className="grid grid-2">
<dl className="kv">
<dt>Status</dt>
<dd>
<span className={`dot ${state}`} />
{stateLabel}
{live.connected && live.connectedSince && <span className="faint"> for {duration(live.connectedSince, now)}</span>}
</dd>
<dt>Tunnel address</dt>
<dd className="mono">
{peer.ipv4}
{peer.ipv6 ? `, ${peer.ipv6}` : ""}
</dd>
<dt>Endpoint</dt>
<dd className="mono">{live.endpoint || "—"}</dd>
<dt>Last handshake</dt>
<dd>
{ago(live.lastHandshake, now)} <span className="faint">{dateTime(live.lastHandshake)}</span>
</dd>
<dt>Rate</dt>
<dd className="num">
{rate(live.rxRate)} · {rate(live.txRate)}
</dd>
<dt>Transfer</dt>
<dd className="num">
{bytes(live.rx)} · {bytes(live.tx)}
</dd>
</dl>
<dl className="kv">
<dt>Public key</dt>
<dd className="mono" title={peer.publicKey}>
{shortKey(peer.publicKey)}{" "}
<button className="btn icon ghost sm" title="Copy" onClick={() => copyText(peer.publicKey).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
<Copy />
</button>
</dd>
<dt>Keys</dt>
<dd>
{peer.serverKeys ? "generated by the server" : "held by the client"}
{peer.presharedKey ? " · preshared key" : ""}
</dd>
<dt>Client routes</dt>
<dd className="mono">{peer.clientRoutes}</dd>
<dt>DNS</dt>
<dd>{peer.dns || <span className="faint">server default ({settings.dns || "none"})</span>}</dd>
<dt>Keepalive / MTU</dt>
<dd>
{peer.keepalive || settings.keepalive}s / {peer.mtu || settings.mtu}
</dd>
<dt>Expires</dt>
<dd>{peer.expiresAt ? dateTime(peer.expiresAt) : <span className="faint">never</span>}</dd>
<dt>Created</dt>
<dd>{dateTime(peer.createdAt)}</dd>
</dl>
</div>
{peer.notes && <p className="mt muted" style={{ whiteSpace: "pre-wrap" }}>{peer.notes}</p>}
<div className="mt" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Legend />
<Segmented value={range} onChange={setRange} options={[{ value: "1h", label: "1h" }, { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, { value: "30d", label: "30d" }]} />
</div>
<TrafficChart points={series} from={now - rangeMs[range]} to={now} bucketSeconds={range === "30d" ? 3600 : 300} />
</>
)}
{tab === "config" && (
<div className="qr">
{peer.serverKeys ? (
<img key={qrKey} src={`/api/peers/${peer.id}/qr.png?size=384&v=${peer.updatedAt}`} alt="QR code of the client configuration" width={320} height={320} onError={() => setQrKey((k) => k + 1)} />
) : (
<div className="notice">This peer holds its own private key, so there is no QR code. Fill in the PrivateKey line on the client.</div>
)}
<pre className="config">{config || "…"}</pre>
<div className="btn-row">
<button className="btn" onClick={() => copyText(config).then((ok) => toast(ok ? "Configuration copied" : "Could not copy", ok ? "ok" : "bad"))} disabled={!config}>
<Copy /> Copy
</button>
<a className="btn" href={`/api/peers/${peer.id}/config?download=1`}>
<Download /> Download .conf
</a>
<span className="small faint">Anyone with this file can connect as this peer. Viewing it is recorded in the audit log.</span>
</div>
</div>
)}
</Modal>
{editing && (
<PeerForm
peer={peer}
settings={settings}
onClose={() => setEditing(false)}
onSaved={(p) => {
setEditing(false);
setConfig("");
toast("Peer saved");
onChanged(p);
}}
/>
)}
{confirm === "delete" && <Confirm title="Delete peer" danger confirmLabel="Delete" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.deletePeer(peer.id).then(() => onClose()), "Peer deleted")} text={<>Delete <b>{peer.name}</b>? Its keys, address and traffic history are gone for good.</>} />}
{confirm === "disable" && <Confirm title="Disconnect peer" confirmLabel="Disconnect" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.disablePeer(peer.id), "Peer disconnected")} text={<>Remove <b>{peer.name}</b> from the interface? Its session drops now and it cannot reconnect until you enable it again.</>} />}
{confirm === "rotate" && (
<Confirm
title="Rotate keys"
confirmLabel="Rotate"
busy={busy}
onClose={() => setConfirm(null)}
onConfirm={() =>
act(
() =>
api.rotatePeer(peer.id).then((p) => {
setConfig(p.config);
setTab("config");
}),
"Keys rotated; hand out the new configuration",
)
}
text={<>Give <b>{peer.name}</b> a new key pair? The configuration it has now stops working the moment you confirm.</>}
/>
)}
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useState, type FormEvent } from "react";
import { api, type Peer, type PeerInput, type Settings } from "../api";
import { fromLocalInput, toLocalInput } from "../format";
import { errorMessage } from "../state";
import { Check, Field, Modal } from "./ui";
// One form for create and edit. On create the key mode is chosen here; on
// edit keys and addresses are fixed (rotate keys from the detail view).
export function PeerForm({ peer, settings, onClose, onSaved }: { peer?: Peer; settings: Settings; onClose: () => void; onSaved: (p: Peer & { config?: string }) => void }) {
const editing = !!peer;
const [name, setName] = useState(peer?.name ?? "");
const [keyMode, setKeyMode] = useState<"server" | "client">("server");
const [publicKey, setPublicKey] = useState("");
const [ipv4, setIpv4] = useState("");
const [ipv6, setIpv6] = useState("");
const [routes, setRoutes] = useState(peer?.clientRoutes ?? settings.clientRoutes);
const [dns, setDns] = useState(peer?.dns ?? "");
const [keepalive, setKeepalive] = useState(peer ? String(peer.keepalive) : "0");
const [mtu, setMtu] = useState(peer ? String(peer.mtu) : "0");
const [expires, setExpires] = useState(toLocalInput(peer?.expiresAt));
const [notes, setNotes] = useState(peer?.notes ?? "");
const [enabled, setEnabled] = useState(peer?.enabled ?? true);
const [advanced, setAdvanced] = useState(editing);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
const body: PeerInput = {
name,
clientRoutes: routes,
dns,
keepalive: keepalive === "" ? null : Number(keepalive),
mtu: mtu === "" ? null : Number(mtu),
enabled,
expiresAt: expires ? fromLocalInput(expires) : "1970-01-01T00:00:00Z",
notes,
};
if (!editing) {
if (keyMode === "client") body.publicKey = publicKey.trim();
if (ipv4.trim()) body.ipv4 = ipv4.trim();
if (ipv6.trim()) body.ipv6 = ipv6.trim();
}
try {
const saved = editing ? await api.updatePeer(peer.id, body) : await api.createPeer(body);
onSaved(saved);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<Modal
title={editing ? `Edit ${peer.name}` : "New peer"}
onClose={onClose}
footer={
<>
<button className="btn" type="button" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="btn primary" type="submit" form="peer-form" disabled={busy}>
{busy ? "…" : editing ? "Save" : "Create peer"}
</button>
</>
}
>
<form id="peer-form" onSubmit={submit}>
{error && <div className="error">{error}</div>}
<Field label="Name" hint="A device or a person: “Laptop”, “Phone”, “Office router”.">
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} required maxLength={64} />
</Field>
{!editing && (
<div className="field">
<label>Keys</label>
<div className="btn-row">
<label className="btn sm" style={{ cursor: "pointer" }}>
<input type="radio" name="keys" checked={keyMode === "server"} onChange={() => setKeyMode("server")} /> Generate here (QR code)
</label>
<label className="btn sm" style={{ cursor: "pointer" }}>
<input type="radio" name="keys" checked={keyMode === "client"} onChange={() => setKeyMode("client")} /> Client brings its own public key
</label>
</div>
<span className="hint">Generating here lets you scan a QR code. Bringing a key means the private key never leaves the client, but there is no QR code.</span>
</div>
)}
{!editing && keyMode === "client" && (
<Field label="Client public key">
<input className="input mono" value={publicKey} onChange={(e) => setPublicKey(e.target.value)} placeholder="base64, 44 characters" required />
</Field>
)}
{!advanced && (
<button type="button" className="btn sm ghost" onClick={() => setAdvanced(true)} style={{ marginLeft: -8 }}>
More options
</button>
)}
{advanced && (
<>
<Field label="Client routes (AllowedIPs)" hint="What the client sends through the tunnel. 0.0.0.0/0, ::/0 is everything; the tunnel subnet alone is split tunnelling.">
<input className="input mono" value={routes} onChange={(e) => setRoutes(e.target.value)} />
</Field>
<div className="form-cols">
<Field label="DNS" hint={`Blank uses the server default (${settings.dns || "none"}).`}>
<input className="input" value={dns} onChange={(e) => setDns(e.target.value)} placeholder={settings.dns} />
</Field>
<Field label="Keepalive (s)" hint={`0 uses the server default (${settings.keepalive}).`}>
<input className="input" type="number" min={0} max={65535} value={keepalive} onChange={(e) => setKeepalive(e.target.value)} />
</Field>
<Field label="MTU" hint={`0 uses the server default (${settings.mtu}).`}>
<input className="input" type="number" min={0} max={9000} value={mtu} onChange={(e) => setMtu(e.target.value)} />
</Field>
<Field label="Expires" hint="The peer is disconnected at this time. Blank never expires.">
<input className="input" type="datetime-local" value={expires} onChange={(e) => setExpires(e.target.value)} />
</Field>
{!editing && (
<>
<Field label="IPv4 address" hint="Blank picks the next free one.">
<input className="input mono" value={ipv4} onChange={(e) => setIpv4(e.target.value)} placeholder="auto" />
</Field>
<Field label="IPv6 address" hint="Only when the server has an IPv6 subnet.">
<input className="input mono" value={ipv6} onChange={(e) => setIpv6(e.target.value)} placeholder="auto" />
</Field>
</>
)}
</div>
<Field label="Notes">
<textarea className="input" value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} />
</Field>
<Check label="Enabled" hint="A disabled peer is removed from the interface and cannot connect." checked={enabled} onChange={setEnabled} />
</>
)}
</form>
</Modal>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import type { TrafficPoint } from "../api";
import { bytes } from "../format";
// Both charts are plain SVG: no library, no runtime dependency, and they
// pick their colours up from the CSS variables so light and dark just work.
export function TrafficChart({ points, from, to, bucketSeconds = 300 }: { points: TrafficPoint[]; from: number; to: number; bucketSeconds?: number }) {
const W = 800;
const H = 180;
const padL = 48;
const padB = 22;
const padT = 8;
const padR = 8;
const { rxPath, txPath, max, ticks, xTicks } = useMemo(() => {
const span = Math.max(1, to - from);
const byBucket = new Map<number, TrafficPoint>();
for (const p of points) byBucket.set(Math.floor(new Date(p.t).getTime() / 1000), p);
// One bar per bucket across the whole window, zeros where nothing was
// recorded, so quiet periods read as quiet rather than missing.
const start = Math.floor(from / 1000 / bucketSeconds) * bucketSeconds;
const end = Math.floor(to / 1000);
const rows: { t: number; rx: number; tx: number }[] = [];
for (let t = start; t <= end; t += bucketSeconds) {
const p = byBucket.get(t);
rows.push({ t, rx: p?.rx ?? 0, tx: p?.tx ?? 0 });
}
const max = Math.max(1, ...rows.map((r) => Math.max(r.rx, r.tx)));
const x = (t: number) => padL + ((t * 1000 - from) / span) * (W - padL - padR);
const y = (v: number) => padT + (1 - v / max) * (H - padT - padB);
const path = (key: "rx" | "tx") => {
if (rows.length === 0) return "";
let d = `M${x(rows[0].t).toFixed(1)},${y(0).toFixed(1)}`;
for (const r of rows) d += ` L${x(r.t).toFixed(1)},${y(r[key]).toFixed(1)}`;
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(rows[rows.length - 1][key]).toFixed(1)}`;
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(0).toFixed(1)} Z`;
return d;
};
const ticks = [0, 0.5, 1].map((f) => ({ v: max * f, y: y(max * f) }));
const xTicks: { x: number; label: string }[] = [];
const n = 6;
for (let i = 0; i <= n; i++) {
const t = from + (span * i) / n;
const d = new Date(t);
const label = span > 2 * 86400 * 1000 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
xTicks.push({ x: padL + (i / n) * (W - padL - padR), label });
}
return { rxPath: path("rx"), txPath: path("tx"), max, ticks, xTicks };
}, [points, from, to, bucketSeconds]);
return (
<svg className="chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Traffic over time">
{ticks.map((t) => (
<g key={t.v}>
<line x1={padL} x2={W - padR} y1={t.y} y2={t.y} stroke="var(--line)" strokeWidth="1" />
<text x={padL - 6} y={t.y + 4} fontSize="10" textAnchor="end" fill="var(--fg-faint)">
{bytes(t.v)}
</text>
</g>
))}
<path d={txPath} fill="var(--tx)" fillOpacity="0.35" stroke="var(--tx)" strokeWidth="1.2" />
<path d={rxPath} fill="var(--rx)" fillOpacity="0.35" stroke="var(--rx)" strokeWidth="1.2" />
{xTicks.map((t, i) => (
<text key={i} x={t.x} y={H - 6} fontSize="10" textAnchor={i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"} fill="var(--fg-faint)">
{t.label}
</text>
))}
<title>peak {bytes(max)} per bucket</title>
</svg>
);
}
export function Sparkline({ values, color = "var(--accent)" }: { values: number[]; color?: string }) {
const W = 110;
const H = 26;
const d = useMemo(() => {
if (values.length < 2) return "";
const max = Math.max(1, ...values);
const step = W / (values.length - 1);
return values.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(H - 2 - (v / max) * (H - 4)).toFixed(1)}`).join(" ");
}, [values]);
return (
<svg className="sparkline" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
export function Legend() {
return (
<div className="legend">
<span>
<i style={{ background: "var(--rx)" }} /> received from peers
</span>
<span>
<i style={{ background: "var(--tx)" }} /> sent to peers
</span>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, type ReactNode } from "react";
import { X } from "lucide-react";
export function Modal({ title, onClose, children, wide, footer }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean; footer?: ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="modal-back"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className={`card modal${wide ? " wide" : ""}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="card-head">
<h2>{title}</h2>
<button className="btn icon ghost" onClick={onClose} aria-label="Close">
<X />
</button>
</div>
<div className="card-body">{children}</div>
{footer && (
<div className="card-head" style={{ borderTop: "1px solid var(--line)", borderBottom: 0, justifyContent: "flex-end" }}>
{footer}
</div>
)}
</div>
</div>
);
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="field">
<label>{label}</label>
{children}
{hint && <span className="hint">{hint}</span>}
</div>
);
}
export function Check({ label, hint, checked, onChange, disabled }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
const id = `chk-${label.replace(/\W+/g, "-").toLowerCase()}`;
return (
<div className="check">
<input id={id} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
<label htmlFor={id}>
{label}
{hint && <span className="hint">{hint}</span>}
</label>
</div>
);
}
export function Confirm({ title, text, confirmLabel = "Confirm", danger, onConfirm, onClose, busy }: { title: string; text: ReactNode; confirmLabel?: string; danger?: boolean; onConfirm: () => void; onClose: () => void; busy?: boolean }) {
return (
<Modal
title={title}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className={`btn ${danger ? "danger" : "primary"}`} onClick={onConfirm} disabled={busy}>
{confirmLabel}
</button>
</>
}
>
<p>{text}</p>
</Modal>
);
}
export function Segmented<T extends string>({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) {
return (
<div className="segmented" role="tablist">
{options.map((o) => (
<button key={o.value} role="tab" aria-selected={o.value === value} className={o.value === value ? "active" : ""} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}