headplane/src/components/dns/DnsPage.tsx
Ryan Malloy 09b1bae157 ui: shadcn-ui + React island shells for pages, Redis session store
Rewrite the dashboard, machines, users, dns, and settings pages as thin
Astro shells that mount a matching React island (Dashboard, MachinesPage,
UsersPage, DnsPage, SettingsPage) built on the shadcn-ui component
library. Alpine.js templates in those pages are replaced wholesale;
Alpine still ships as the runtime for the ACL editor's dependencies.

- src/components/ui/*: shadcn primitives (button, card, tabs, select,
  dropdown-menu, avatar, badge, input, switch, separator)
- src/components/{dashboard,dns,machines,settings,users}/*: page-level
  React shells that consume server props from the Astro parent
- src/components/shell/AppShell.tsx: shared chrome (nav, user menu)
- src/lib/utils.ts: shadcn's cn() helper
- src/lib/auth/session-manager.ts: pluggable SessionStore interface with
  Redis (via REDIS_URL) or in-memory backends; both honor absolute
  expiration via TTL. In-memory logs a warning that sessions vanish on
  restart.
- src/lib/auth/oidc-client.ts, oidc-state.ts: shrink OIDC handlers now
  that PKCE + nonce state travels in a signed JWT cookie
- src/pages/api/auth/*: match the simplified handlers
- src/styles/global.css, tailwind.config.mjs, tsconfig.json: shadcn
  design tokens and the '@/*' path alias
- docker-compose.local.yml: local dev tweaks
- package.json, pnpm-lock.yaml: shadcn + Radix + ioredis + zod
2026-08-20 10:25:35 -06:00

512 lines
13 KiB
TypeScript

// 🤠 Heady DNS page — React + shadcn rebuild.
import {
Globe,
Network,
Plus,
RotateCcw,
Save,
Server,
Trash2,
} from 'lucide-react';
import * as React from 'react';
import { AppShell } from '@/components/shell/AppShell';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX';
export interface DnsRecord {
name: string;
type: DnsRecordType;
value: string;
}
export interface DnsConfig {
prefixes: string[];
magicDns: boolean;
baseDomain: string;
nameservers: string[];
splitDns: Record<string, string[]>;
searchDomains: string[];
overrideDns: boolean;
extraRecords: DnsRecord[];
}
export interface DnsPageProps {
initial: DnsConfig;
canEdit?: boolean;
}
export function DnsPage(props: DnsPageProps) {
return (
<AppShell currentPath="/dns" requiredRole="network_admin">
{() => <DnsBody {...props} />}
</AppShell>
);
}
function DnsBody({ initial, canEdit = true }: DnsPageProps) {
const [config, setConfig] = React.useState<DnsConfig>(initial);
const [dirty, setDirty] = React.useState(false);
const update = <K extends keyof DnsConfig>(key: K, value: DnsConfig[K]) => {
setConfig((c) => ({ ...c, [key]: value }));
setDirty(true);
};
const reset = () => {
setConfig(initial);
setDirty(false);
};
const save = () => {
// TODO: PATCH /api/dns once the backend lands.
console.log('Would save DNS config', config);
setDirty(false);
};
return (
<div className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-tight">DNS</h1>
<p className="mt-1 text-sm text-muted-foreground">
Magic DNS, nameservers, search domains, and custom records for your
tailnet.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={reset} disabled={!dirty}>
<RotateCcw />
Discard
</Button>
<Button onClick={save} disabled={!canEdit || !dirty}>
<Save />
Save changes
</Button>
</div>
</header>
{/* Magic DNS toggle row */}
<Card>
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Magic DNS</p>
<p className="text-xs text-muted-foreground">
Auto-resolve devices by name on{' '}
<code className="rounded bg-muted px-1 text-xs">
{config.baseDomain || '<base-domain>'}
</code>
.
</p>
</div>
<Switch
checked={config.magicDns}
onCheckedChange={(v) => update('magicDns', v)}
disabled={!canEdit}
/>
</CardContent>
</Card>
{/* Base domain + override toggle */}
<Card>
<CardHeader>
<CardTitle>Base domain</CardTitle>
<CardDescription>
Suffix appended to every Magic DNS hostname.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
Domain
</label>
<Input
value={config.baseDomain}
onChange={(e) => update('baseDomain', e.target.value)}
placeholder="heady.local"
disabled={!canEdit}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-input p-3">
<div>
<p className="text-sm font-medium">Override local DNS</p>
<p className="text-xs text-muted-foreground">
Force clients to use these nameservers.
</p>
</div>
<Switch
checked={config.overrideDns}
onCheckedChange={(v) => update('overrideDns', v)}
disabled={!canEdit}
/>
</div>
</CardContent>
</Card>
{/* Tailnet prefixes (read-only) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
Tailnet prefixes
</CardTitle>
<CardDescription>
Allocated by Headscale. Read-only.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{config.prefixes.map((p) => (
<Badge key={p} variant="outline" className="font-mono text-xs">
{p}
</Badge>
))}
</CardContent>
</Card>
{/* String list cards */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<StringListCard
title="Global nameservers"
description="Resolvers used by every device in the tailnet."
icon={<Server className="size-4 text-muted-foreground" />}
values={config.nameservers}
placeholder="1.1.1.1"
onChange={(v) => update('nameservers', v)}
disabled={!canEdit}
/>
<StringListCard
title="Search domains"
description="Domains tried for bare hostnames."
icon={<Globe className="size-4 text-muted-foreground" />}
values={config.searchDomains}
placeholder="company.com"
onChange={(v) => update('searchDomains', v)}
disabled={!canEdit}
/>
</div>
{/* Split DNS */}
<SplitDnsCard
rules={config.splitDns}
onChange={(v) => update('splitDns', v)}
disabled={!canEdit}
/>
{/* Extra records */}
<ExtraRecordsCard
records={config.extraRecords}
onChange={(v) => update('extraRecords', v)}
disabled={!canEdit}
/>
</div>
);
}
/* ───── String list editor (nameservers, search domains) ───── */
function StringListCard({
title,
description,
icon,
values,
placeholder,
onChange,
disabled,
}: {
title: string;
description: string;
icon: React.ReactNode;
values: string[];
placeholder: string;
onChange: (v: string[]) => void;
disabled?: boolean;
}) {
const [draft, setDraft] = React.useState('');
const add = () => {
const v = draft.trim();
if (!v || values.includes(v)) return;
onChange([...values, v]);
setDraft('');
};
const remove = (i: number) => {
onChange(values.filter((_, idx) => idx !== i));
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">{icon}{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{values.length === 0 && (
<p className="text-sm text-muted-foreground">No entries yet.</p>
)}
<ul className="space-y-2">
{values.map((v, i) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: order-stable list
key={i}
className="flex items-center justify-between rounded-md border border-input bg-muted/30 px-3 py-2 text-sm"
>
<span className="font-mono">{v}</span>
<Button
variant="ghost"
size="icon"
onClick={() => remove(i)}
disabled={disabled}
aria-label={`Remove ${v}`}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
add();
}
}}
disabled={disabled}
/>
<Button variant="outline" onClick={add} disabled={disabled || !draft.trim()}>
<Plus />
Add
</Button>
</div>
</CardContent>
</Card>
);
}
/* ───── Split DNS ───── */
function SplitDnsCard({
rules,
onChange,
disabled,
}: {
rules: Record<string, string[]>;
onChange: (v: Record<string, string[]>) => void;
disabled?: boolean;
}) {
const [domain, setDomain] = React.useState('');
const [servers, setServers] = React.useState('');
const add = () => {
const d = domain.trim();
const s = servers
.split(',')
.map((x) => x.trim())
.filter(Boolean);
if (!d || s.length === 0) return;
onChange({ ...rules, [d]: s });
setDomain('');
setServers('');
};
const remove = (key: string) => {
const next = { ...rules };
delete next[key];
onChange(next);
};
const entries = Object.entries(rules);
return (
<Card>
<CardHeader>
<CardTitle>Split DNS</CardTitle>
<CardDescription>
Per-domain resolvers. Queries for these domains skip the global list.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">No split DNS rules.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-input">
{entries.map(([d, ips]) => (
<li
key={d}
className="flex items-center justify-between gap-2 px-3 py-2 text-sm"
>
<div>
<p className="font-mono text-foreground">{d}</p>
<p className="font-mono text-xs text-muted-foreground">
{ips.join(', ')}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => remove(d)}
disabled={disabled}
aria-label={`Remove ${d}`}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_2fr_auto]">
<Input
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="corp.example.com"
disabled={disabled}
/>
<Input
value={servers}
onChange={(e) => setServers(e.target.value)}
placeholder="10.0.0.1, 10.0.0.2"
disabled={disabled}
/>
<Button variant="outline" onClick={add} disabled={disabled}>
<Plus />
Add rule
</Button>
</div>
</CardContent>
</Card>
);
}
/* ───── Extra records ───── */
function ExtraRecordsCard({
records,
onChange,
disabled,
}: {
records: DnsRecord[];
onChange: (v: DnsRecord[]) => void;
disabled?: boolean;
}) {
const [name, setName] = React.useState('');
const [type, setType] = React.useState<DnsRecordType>('A');
const [value, setValue] = React.useState('');
const add = () => {
if (!name.trim() || !value.trim()) return;
onChange([...records, { name: name.trim(), type, value: value.trim() }]);
setName('');
setValue('');
};
const remove = (i: number) => onChange(records.filter((_, idx) => idx !== i));
return (
<Card>
<CardHeader>
<CardTitle>Custom records</CardTitle>
<CardDescription>
Extra A/AAAA/CNAME/TXT/MX records served alongside Magic DNS.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 p-0">
{records.length === 0 ? (
<p className="px-6 pb-6 text-sm text-muted-foreground">
No custom records.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-secondary/30 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Name</th>
<th className="px-4 py-2 text-left font-medium">Type</th>
<th className="px-4 py-2 text-left font-medium">Value</th>
<th className="w-10" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{records.map((r, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable position
<tr key={`${r.name}-${r.type}-${i}`}>
<td className="px-4 py-2 font-mono">{r.name}</td>
<td className="px-4 py-2">
<Badge variant="outline" className="font-mono text-[10px]">
{r.type}
</Badge>
</td>
<td className="px-4 py-2 font-mono text-muted-foreground">
{r.value}
</td>
<td className="px-4 py-2 text-right">
<Button
variant="ghost"
size="icon"
onClick={() => remove(i)}
disabled={disabled}
aria-label={`Remove ${r.name}`}
>
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="grid grid-cols-1 gap-2 px-6 pb-6 sm:grid-cols-[2fr_auto_2fr_auto]">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="api.heady.local"
disabled={disabled}
/>
<Select
value={type}
onValueChange={(v) => setType(v as DnsRecordType)}
disabled={disabled}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A</SelectItem>
<SelectItem value="AAAA">AAAA</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
<SelectItem value="TXT">TXT</SelectItem>
<SelectItem value="MX">MX</SelectItem>
</SelectContent>
</Select>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="100.64.0.10"
disabled={disabled}
/>
<Button variant="outline" onClick={add} disabled={disabled}>
<Plus />
Add record
</Button>
</div>
</CardContent>
</Card>
);
}