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
This commit is contained in:
Ryan Malloy 2026-08-20 10:25:35 -06:00
parent 36c1b18724
commit 09b1bae157
38 changed files with 4966 additions and 3972 deletions

View File

@ -32,6 +32,10 @@ services:
timeout: 3s
volumes:
- heady_redis_data:/data
# Expose to localhost so the Heady dev server (running on the host) can
# use the same Redis instance Authentik uses for its session store.
ports:
- '127.0.0.1:6389:6379'
networks: [authentik-internal]
heady-authentik-server:

View File

@ -27,16 +27,27 @@
"@astrojs/tailwind": "^5.1.2",
"@kubernetes/client-node": "^1.3.0",
"@libsql/client": "0.15.12",
"@radix-ui/react-avatar": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-dropdown-menu": "^2.1.17",
"@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-tabs": "^1.1.14",
"@radix-ui/react-tooltip": "^1.2.9",
"@shopify/lang-jsonc": "^1.0.1",
"alpinejs": "^3.14.1",
"arktype": "^2.1.20",
"astro": "^4.16.18",
"chart.js": "^4.4.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "17.2.1",
"drizzle-orm": "0.44.4",
"guacamole-lite": "^1.2.0",
"ioredis": "^5.11.1",
"ip-address": "^9.0.5",
"is-cidr": "^5.0.3",
"jose": "6.1.0",

1029
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,406 @@
// 🤠 Heady Dashboard — React replacement for the Alpine-driven home page.
import {
Activity,
ArrowRight,
CheckCircle2,
CircleDot,
Database,
Globe,
HardDrive,
KeyRound,
Network,
Plus,
Server,
Shield,
Terminal,
Users,
Wifi,
XCircle,
} from 'lucide-react';
import * as React from 'react';
import { AppShell, type SessionUser } 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';
export interface Machine {
id: string;
name: string;
ip_address: string;
os: string;
online: boolean;
}
export interface Session {
id: string;
node_name: string;
user_email: string;
protocol: 'ssh' | 'rdp' | 'vnc' | 'telnet' | 'kubernetes';
status: string;
}
export interface DashboardProps {
machines: Machine[];
activeSessions: Session[];
}
export function DashboardPage(props: DashboardProps) {
return (
<AppShell currentPath="/" requiredRole="member">
{(user) => <DashboardBody user={user} {...props} />}
</AppShell>
);
}
function DashboardBody({
user,
machines,
activeSessions,
}: DashboardProps & { user: SessionUser }) {
const onlineMachines = machines.filter((m) => m.online).length;
const systemHealth = {
headscale: true,
database: true,
oidc: true,
remoteAccess: true,
};
const everythingHealthy = Object.values(systemHealth).every((v) => v);
return (
<div className="space-y-8">
{/* ─── Welcome header ─────────────────────────────── */}
<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">
Welcome back, {user.name.split(' ')[0]}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Strategic VPN management that's actually pleasant to use.
</p>
</div>
<div className="flex gap-2">
<Button asChild>
<a href="/terminal">
<Terminal />
Quick Terminal
</a>
</Button>
<Button variant="secondary" asChild>
<a href="/machines">
<Server />
Machines
</a>
</Button>
</div>
</header>
{/* ─── Stats grid ─────────────────────────────────── */}
<section className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Server className="size-4" />}
label="Online Machines"
value={`${onlineMachines}`}
sub={`of ${machines.length}`}
tone="emerald"
/>
<StatCard
icon={<Activity className="size-4" />}
label="Active Sessions"
value={`${activeSessions.length}`}
tone="sky"
/>
<StatCard
icon={<Shield className="size-4" />}
label="System Health"
value={everythingHealthy ? 'Healthy' : 'Degraded'}
tone={everythingHealthy ? 'emerald' : 'amber'}
/>
<StatCard
icon={<Wifi className="size-4" />}
label="Network"
value="Online"
tone="violet"
/>
</section>
{/* ─── Main grid: machines + sidebar ─────────────── */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<MachinesOverview machines={machines} className="lg:col-span-2" />
<div className="space-y-6">
<ActiveSessionsCard sessions={activeSessions} />
<SystemHealthCard health={systemHealth} />
<QuickActionsCard />
</div>
</section>
</div>
);
}
/* ────────────────────────────────────────── Stat tile ──── */
function StatCard({
icon,
label,
value,
sub,
tone,
}: {
icon: React.ReactNode;
label: string;
value: string;
sub?: string;
tone: 'emerald' | 'sky' | 'amber' | 'violet';
}) {
const toneClasses: Record<typeof tone, string> = {
emerald: 'bg-emerald-500/10 text-emerald-400',
sky: 'bg-sky-500/10 text-sky-400',
amber: 'bg-amber-500/10 text-amber-400',
violet: 'bg-violet-500/10 text-violet-400',
};
return (
<Card>
<CardContent className="flex items-center gap-4 p-5">
<div
className={`flex size-10 items-center justify-center rounded-lg ${toneClasses[tone]}`}
>
{icon}
</div>
<div className="min-w-0">
<p className="truncate text-xs uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-2xl font-semibold leading-tight">
{value}
{sub && (
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{sub}
</span>
)}
</p>
</div>
</CardContent>
</Card>
);
}
/* ────────────────────────────────────────── Machines ──── */
function MachinesOverview({
machines,
className,
}: {
machines: Machine[];
className?: string;
}) {
const visible = machines.slice(0, 5);
const overflow = machines.length - visible.length;
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<HardDrive className="size-4 text-muted-foreground" />
Machines
</CardTitle>
<CardDescription>Connected devices in your tailnet</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<a href="/machines">
View all
<ArrowRight className="ml-1" />
</a>
</Button>
</CardHeader>
<CardContent>
{visible.length === 0 ? (
<EmptyState
icon={<Server className="size-8" />}
title="No machines yet"
description="Connect your first device to see it here."
/>
) : (
<ul className="divide-y divide-border">
{visible.map((m) => (
<li
key={m.id}
className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"
>
<CircleDot
className={
m.online
? 'size-3 text-emerald-400'
: 'size-3 text-muted-foreground'
}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{m.name}</p>
<p className="truncate font-mono text-xs text-muted-foreground">
{m.ip_address}
</p>
</div>
<Badge variant="outline" className="text-xs capitalize">
{m.os}
</Badge>
<Badge variant={m.online ? 'success' : 'secondary'}>
{m.online ? 'Online' : 'Offline'}
</Badge>
</li>
))}
</ul>
)}
{overflow > 0 && (
<p className="mt-3 text-center text-xs text-muted-foreground">
and {overflow} more
</p>
)}
</CardContent>
</Card>
);
}
/* ────────────────────────────────────────── Sessions ──── */
function ActiveSessionsCard({ sessions }: { sessions: Session[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="size-4 text-muted-foreground" />
Active Sessions
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">No active sessions.</p>
) : (
sessions.slice(0, 4).map((s) => (
<div
key={s.id}
className="flex items-center justify-between rounded-md border border-border bg-secondary/30 px-3 py-2"
>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="font-mono text-[10px] uppercase"
>
{s.protocol}
</Badge>
<span className="font-mono text-xs">{s.node_name}</span>
</div>
<span className="text-xs text-muted-foreground">
{s.user_email.split('@')[0]}
</span>
</div>
))
)}
</CardContent>
</Card>
);
}
/* ────────────────────────────────────── System health ──── */
function SystemHealthCard({
health,
}: {
health: { headscale: boolean; database: boolean; oidc: boolean; remoteAccess: boolean };
}) {
const rows: { label: string; ok: boolean; icon: React.ReactNode }[] = [
{ label: 'Headscale', ok: health.headscale, icon: <Network className="size-4" /> },
{ label: 'Database', ok: health.database, icon: <Database className="size-4" /> },
{ label: 'OIDC Auth', ok: health.oidc, icon: <KeyRound className="size-4" /> },
{ label: 'Remote Access', ok: health.remoteAccess, icon: <Terminal className="size-4" /> },
];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="size-4 text-muted-foreground" />
System Health
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-2 text-muted-foreground">
{r.icon}
{r.label}
</span>
{r.ok ? (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="size-4" />
Healthy
</span>
) : (
<span className="flex items-center gap-1 text-destructive">
<XCircle className="size-4" />
Degraded
</span>
)}
</div>
))}
</CardContent>
</Card>
);
}
/* ────────────────────────────────────── Quick actions ──── */
function QuickActionsCard() {
const items: { href: string; label: string; icon: React.ReactNode }[] = [
{ href: '/machines/add', label: 'Add Machine', icon: <Plus /> },
{ href: '/acls', label: 'Configure ACLs', icon: <Shield /> },
{ href: '/dns', label: 'Manage DNS', icon: <Globe /> },
{ href: '/users', label: 'User Management', icon: <Users /> },
];
return (
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{items.map((it) => (
<Button
key={it.href}
variant="secondary"
className="w-full justify-start"
asChild
>
<a href={it.href}>
{it.icon}
{it.label}
</a>
</Button>
))}
</CardContent>
</Card>
);
}
/* ────────────────────────────────────── Empty state ──── */
function EmptyState({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
{icon}
<p className="font-medium text-foreground">{title}</p>
<p className="text-sm">{description}</p>
</div>
);
}

View File

@ -0,0 +1,512 @@
// 🤠 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>
);
}

View File

@ -0,0 +1,356 @@
// 🤠 Heady Machines page — React + shadcn rebuild.
import {
CircleDot,
Filter,
HardDrive,
MoreHorizontal,
Plus,
RefreshCw,
Search,
Server,
Tag,
} from 'lucide-react';
import * as React from 'react';
import { AppShell, type SessionUser } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface MachineRow {
id: string;
name: string;
hostname?: string;
ip_address: string;
online: boolean;
expired?: boolean;
os: 'linux' | 'windows' | 'macos' | 'android' | 'ios' | 'unknown';
user?: string;
tags?: string[];
last_seen?: string;
}
export interface MachinesPageProps {
machines: MachineRow[];
}
export function MachinesPage(props: MachinesPageProps) {
return (
<AppShell currentPath="/machines" requiredRole="member">
{(user) => <MachinesBody user={user} {...props} />}
</AppShell>
);
}
function MachinesBody({
user,
machines,
}: MachinesPageProps & { user: SessionUser }) {
const [search, setSearch] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState<string>('all');
const [osFilter, setOsFilter] = React.useState<string>('all');
const allOsValues = React.useMemo(() => {
const set = new Set<string>();
machines.forEach((m) => set.add(m.os));
return Array.from(set).sort();
}, [machines]);
const filtered = React.useMemo(() => {
const needle = search.trim().toLowerCase();
return machines.filter((m) => {
if (statusFilter === 'online' && !m.online) return false;
if (statusFilter === 'offline' && m.online) return false;
if (osFilter !== 'all' && m.os !== osFilter) return false;
if (!needle) return true;
return (
m.name.toLowerCase().includes(needle) ||
m.ip_address.toLowerCase().includes(needle) ||
(m.user ?? '').toLowerCase().includes(needle) ||
(m.tags ?? []).some((t) => t.toLowerCase().includes(needle))
);
});
}, [machines, search, statusFilter, osFilter]);
const onlineCount = machines.filter((m) => m.online).length;
const expiredCount = machines.filter((m) => m.expired).length;
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">Machines</h1>
<p className="mt-1 text-sm text-muted-foreground">
Every device connected to your Headscale tailnet.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline">
<RefreshCw />
Refresh
</Button>
<Button>
<Plus />
Add Machine
</Button>
</div>
</header>
{/* Stat strip */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat label="Total" value={machines.length} icon={<Server className="size-4" />} />
<Stat
label="Online"
value={onlineCount}
tone="emerald"
icon={<CircleDot className="size-4" />}
/>
<Stat
label="Offline"
value={machines.length - onlineCount}
tone="muted"
icon={<CircleDot className="size-4" />}
/>
<Stat
label="Expired"
value={expiredCount}
tone={expiredCount > 0 ? 'amber' : 'muted'}
icon={<HardDrive className="size-4" />}
/>
</section>
{/* Filter bar */}
<Card>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search name, IP, user, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
</SelectContent>
</Select>
<Select value={osFilter} onValueChange={setOsFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All OS</SelectItem>
{allOsValues.map((os) => (
<SelectItem key={os} value={os}>
{os}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Results */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Devices</CardTitle>
<CardDescription>
{filtered.length} of {machines.length} shown
</CardDescription>
</div>
<Filter className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent className="p-0">
{filtered.length === 0 ? (
<EmptyResult />
) : (
<MachinesTable machines={filtered} />
)}
</CardContent>
</Card>
</div>
);
}
/* ───── stat ───── */
function Stat({
label,
value,
icon,
tone = 'sky',
}: {
label: string;
value: number;
icon: React.ReactNode;
tone?: 'sky' | 'emerald' | 'amber' | 'muted';
}) {
const tones: Record<typeof tone, string> = {
sky: 'bg-sky-500/10 text-sky-400',
emerald: 'bg-emerald-500/10 text-emerald-400',
amber: 'bg-amber-500/10 text-amber-400',
muted: 'bg-muted text-muted-foreground',
};
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div
className={`flex size-9 items-center justify-center rounded-lg ${tones[tone]}`}
>
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-xl font-semibold leading-tight">{value}</p>
</div>
</CardContent>
</Card>
);
}
/* ───── table ───── */
function MachinesTable({ machines }: { machines: MachineRow[] }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-secondary/30 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Name</th>
<th className="px-4 py-3 text-left font-medium">IP</th>
<th className="px-4 py-3 text-left font-medium">OS</th>
<th className="px-4 py-3 text-left font-medium">User</th>
<th className="px-4 py-3 text-left font-medium">Tags</th>
<th className="w-10 px-4 py-3 text-right font-medium" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{machines.map((m) => (
<tr key={m.id} className="transition-colors hover:bg-secondary/20">
<td className="px-4 py-3">
<Badge variant={m.online ? 'success' : 'secondary'}>
{m.online ? 'Online' : 'Offline'}
</Badge>
{m.expired && (
<Badge variant="warning" className="ml-1">
expired
</Badge>
)}
</td>
<td className="px-4 py-3">
<div className="font-medium">{m.name}</div>
{m.hostname && m.hostname !== m.name && (
<div className="text-xs text-muted-foreground">
{m.hostname}
</div>
)}
</td>
<td className="px-4 py-3 font-mono text-xs">{m.ip_address}</td>
<td className="px-4 py-3 capitalize text-muted-foreground">
{m.os}
</td>
<td className="px-4 py-3 text-muted-foreground">
{m.user ?? '—'}
</td>
<td className="px-4 py-3">
{m.tags && m.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{m.tags.map((t) => (
<Badge
key={t}
variant="outline"
className="font-mono text-[10px]"
>
<Tag className="mr-1 size-2.5" />
{t}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<MachineRowMenu machine={m} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function MachineRowMenu({ machine }: { machine: MachineRow }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Row actions">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="truncate">{machine.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard?.writeText(machine.ip_address).catch(() => {});
}}
>
Copy IP
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/machines/${machine.id}`}>Details</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`/terminal?machine=${machine.id}`}>Open terminal</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function EmptyResult() {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center text-muted-foreground">
<Server className="size-8" />
<p className="font-medium text-foreground">No matching machines</p>
<p className="text-sm">Try adjusting your search or filters.</p>
</div>
);
}

View File

@ -0,0 +1,648 @@
// ⚙️ Heady Settings page — React + shadcn rebuild with tabs.
import {
CheckCircle2,
FileText,
KeyRound,
Plus,
Server,
Settings,
Shield,
Trash2,
UserCheck,
XCircle,
} from 'lucide-react';
import * as React from 'react';
import { AppShell, type SessionUser } 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';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export interface AuthKey {
id: string;
keyPrefix: string;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string;
createdAt: string;
user: { id: string; name: string; email: string };
}
export interface SettingsData {
server: {
version: string;
url: string;
publicUrl: string;
grpcListenAddr: string;
metricsListenAddr: string;
};
oidc: {
enabled: boolean;
issuer: string;
clientId: string;
stripEmailDomain: boolean;
scope: string[];
allowedDomains: string[];
allowedGroups: string[];
};
database: { type: string; sqlite: { path: string } };
tls: { letsencrypt: { hostname: string; challengeType: string } };
log: { level: string; format: string };
}
export interface Permissions {
canGenerateAuthKeys: boolean;
canModifyConfig: boolean;
canViewLogs: boolean;
}
/** Heady runtime info — pulled from process env + the session store. */
export interface HeadyRuntime {
sessionStore: 'redis' | 'in-memory';
sessionLifetime: string;
sessionCookieName: string;
cookieSecure: boolean;
activeSessions: number;
nodeVersion: string;
processUptime: number;
}
export interface SettingsPageProps {
settings: SettingsData;
authKeys: AuthKey[];
permissions: Permissions;
heady: HeadyRuntime;
}
export function SettingsPage(props: SettingsPageProps) {
return (
<AppShell currentPath="/settings" requiredRole="admin">
{(user) => <SettingsBody me={user} {...props} />}
</AppShell>
);
}
function SettingsBody({
me,
settings,
authKeys,
permissions,
heady,
}: SettingsPageProps & { me: SessionUser }) {
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-semibold tracking-tight">Settings</h1>
<p className="mt-1 text-sm text-muted-foreground">
Configure your Heady deployment, manage pre-auth keys, and review
identity restrictions.
</p>
</header>
{/* Headline strip */}
<section className="grid grid-cols-1 gap-4 md:grid-cols-4">
<HeadlineStat
icon={<Server className="size-4 text-muted-foreground" />}
label="Headscale"
value={
settings.server.version.includes('connect')
? 'not connected'
: `v${settings.server.version}`
}
tone={settings.server.version.includes('connect') ? 'amber' : 'sky'}
/>
<HeadlineStat
icon={<Shield className="size-4 text-muted-foreground" />}
label="OIDC"
value={settings.oidc.enabled ? 'Enabled' : 'Disabled'}
tone={settings.oidc.enabled ? 'emerald' : 'amber'}
/>
<HeadlineStat
icon={<KeyRound className="size-4 text-muted-foreground" />}
label="Sessions"
value={`${heady.activeSessions} active`}
tone="sky"
/>
<HeadlineStat
icon={<FileText className="size-4 text-muted-foreground" />}
label="Session store"
value={heady.sessionStore}
tone={heady.sessionStore === 'redis' ? 'emerald' : 'amber'}
/>
</section>
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">
<Settings />
Overview
</TabsTrigger>
<TabsTrigger value="auth-keys">
<KeyRound />
Auth Keys
</TabsTrigger>
<TabsTrigger value="oidc">
<UserCheck />
OIDC
</TabsTrigger>
<TabsTrigger value="advanced">
<FileText />
Advanced
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewTab settings={settings} heady={heady} />
</TabsContent>
<TabsContent value="auth-keys">
<AuthKeysTab
authKeys={authKeys}
canCreate={permissions.canGenerateAuthKeys}
/>
</TabsContent>
<TabsContent value="oidc">
<OidcTab oidc={settings.oidc} canEdit={permissions.canModifyConfig} />
</TabsContent>
<TabsContent value="advanced">
<AdvancedTab settings={settings} canEdit={permissions.canModifyConfig} />
</TabsContent>
</Tabs>
</div>
);
}
function HeadlineStat({
icon,
label,
value,
tone = 'sky',
}: {
icon: React.ReactNode;
label: string;
value: string;
tone?: 'sky' | 'emerald' | 'amber';
}) {
const tones = {
sky: 'bg-sky-500/10 text-sky-400',
emerald: 'bg-emerald-500/10 text-emerald-400',
amber: 'bg-amber-500/10 text-amber-400',
};
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={`flex size-9 items-center justify-center rounded-lg ${tones[tone]}`}>
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-xl font-semibold leading-tight">{value}</p>
</div>
</CardContent>
</Card>
);
}
/* ───── Overview tab ───── */
function OverviewTab({
settings,
heady,
}: {
settings: SettingsData;
heady: HeadyRuntime;
}) {
// Heady-side rows — these we know for certain.
const headyRows: { label: string; value: string }[] = [
{ label: 'Public URL', value: settings.server.publicUrl },
{ label: 'Session store', value: heady.sessionStore },
{ label: 'Session lifetime', value: heady.sessionLifetime },
{ label: 'Session cookie', value: heady.sessionCookieName },
{
label: 'Cookie secure',
value: heady.cookieSecure ? 'true' : 'false (dev)',
},
{ label: 'Active sessions', value: String(heady.activeSessions) },
{ label: 'Node.js', value: heady.nodeVersion },
{ label: 'Process uptime', value: `${heady.processUptime}s` },
];
// Headscale-side rows — placeholders until the integration lands.
const headscaleRows: { label: string; value: string }[] = [
{ label: 'Version', value: settings.server.version },
{ label: 'URL', value: settings.server.url },
{ label: 'gRPC listen', value: settings.server.grpcListenAddr },
{ label: 'Metrics listen', value: settings.server.metricsListenAddr },
{
label: 'Database',
value:
settings.database.type.includes('connect')
? settings.database.type
: `${settings.database.type} (${settings.database.sqlite.path})`,
},
{ label: 'TLS hostname', value: settings.tls.letsencrypt.hostname },
{ label: 'TLS challenge', value: settings.tls.letsencrypt.challengeType },
];
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Heady runtime</CardTitle>
<CardDescription>
Live state of this Heady instance.
</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
{headyRows.map((r) => (
<div
key={r.label}
className="flex items-start justify-between gap-4 border-b border-border py-2"
>
<dt className="text-muted-foreground">{r.label}</dt>
<dd className="font-mono text-right text-foreground">
{r.value}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Headscale configuration</CardTitle>
<CardDescription>
Populated from the Headscale API once it's wired. Until then,
placeholder values are shown in dimmed text.
</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
{headscaleRows.map((r) => {
const isPlaceholder = r.value.includes('connect');
return (
<div
key={r.label}
className="flex items-start justify-between gap-4 border-b border-border py-2"
>
<dt className="text-muted-foreground">{r.label}</dt>
<dd
className={
isPlaceholder
? 'text-right text-xs italic text-muted-foreground/60'
: 'font-mono text-right text-foreground'
}
>
{r.value}
</dd>
</div>
);
})}
</dl>
</CardContent>
</Card>
</div>
);
}
/* ───── Auth keys tab ───── */
function AuthKeysTab({
authKeys,
canCreate,
}: {
authKeys: AuthKey[];
canCreate: boolean;
}) {
const now = Date.now();
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Pre-auth keys</CardTitle>
<CardDescription>
Issue tokens devices can use to join the tailnet without OIDC.
</CardDescription>
</div>
<Button disabled={!canCreate}>
<Plus />
Generate key
</Button>
</CardHeader>
<CardContent className="p-0">
{authKeys.length === 0 ? (
<div className="px-6 py-12 text-center text-sm text-muted-foreground">
No auth keys yet.
</div>
) : (
<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">Key</th>
<th className="px-4 py-2 text-left font-medium">User</th>
<th className="px-4 py-2 text-left font-medium">Flags</th>
<th className="px-4 py-2 text-left font-medium">Expires</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="w-10" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{authKeys.map((k) => {
const expired = new Date(k.expiration).getTime() < now;
return (
<tr key={k.id} className="transition-colors hover:bg-secondary/20">
<td className="px-4 py-3 font-mono text-xs">
{k.keyPrefix}
</td>
<td className="px-4 py-3">
<div className="font-medium">{k.user.name}</div>
<div className="text-xs text-muted-foreground">{k.user.email}</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{k.reusable && <Badge variant="outline">reusable</Badge>}
{k.ephemeral && <Badge variant="outline">ephemeral</Badge>}
{!k.reusable && !k.ephemeral && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(k.expiration).toLocaleDateString()}
</td>
<td className="px-4 py-3">
{expired ? (
<Badge variant="warning">expired</Badge>
) : k.used ? (
<Badge variant="secondary">used</Badge>
) : (
<Badge variant="success">active</Badge>
)}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" aria-label="Revoke">
<Trash2 className="size-4 text-muted-foreground" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
/* ───── OIDC tab ───── */
function OidcTab({
oidc,
canEdit,
}: {
oidc: SettingsData['oidc'];
canEdit: boolean;
}) {
const [enabled, setEnabled] = React.useState(oidc.enabled);
const [issuer, setIssuer] = React.useState(oidc.issuer);
const [clientId, setClientId] = React.useState(oidc.clientId);
const [strip, setStrip] = React.useState(oidc.stripEmailDomain);
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-sm font-medium">OIDC authentication</p>
<p className="text-xs text-muted-foreground">
Identity provider for tailnet user accounts.
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
disabled={!canEdit}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Provider</CardTitle>
<CardDescription>
OIDC issuer + client. Secrets are managed via environment.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Issuer">
<Input
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
disabled={!canEdit || !enabled}
/>
</Field>
<Field label="Client ID">
<Input
value={clientId}
onChange={(e) => setClientId(e.target.value)}
disabled={!canEdit || !enabled}
/>
</Field>
<Field label="Client secret">
<Input
value="••••••••"
readOnly
disabled
className="font-mono text-xs"
/>
</Field>
<Field label="Strip email domain">
<div className="flex h-9 items-center">
<Switch
checked={strip}
onCheckedChange={setStrip}
disabled={!canEdit || !enabled}
/>
</div>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Restrictions</CardTitle>
<CardDescription>
Limit who can log in via OIDC.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<RestrictionList
title="Allowed domains"
values={oidc.allowedDomains}
hint="Block emails outside these domains."
/>
<RestrictionList
title="Allowed groups"
values={oidc.allowedGroups}
hint="Only members of these groups can log in."
/>
</CardContent>
</Card>
</div>
);
}
function RestrictionList({
title,
values,
hint,
}: {
title: string;
values: string[];
hint: string;
}) {
return (
<div className="space-y-2 rounded-md border border-input p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{values.length}</p>
</div>
<p className="text-xs text-muted-foreground">{hint}</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{values.length === 0 ? (
<span className="text-xs text-muted-foreground">none</span>
) : (
values.map((v) => (
<Badge key={v} variant="outline" className="font-mono">
{v}
</Badge>
))
)}
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
);
}
/* ───── Advanced tab ───── */
function AdvancedTab({
settings,
canEdit,
}: {
settings: SettingsData;
canEdit: boolean;
}) {
const [logLevel, setLogLevel] = React.useState(settings.log.level);
const [logFormat, setLogFormat] = React.useState(settings.log.format);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Logging</CardTitle>
<CardDescription>Verbosity and format of Headscale logs.</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Level">
<Select value={logLevel} onValueChange={setLogLevel} disabled={!canEdit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trace">trace</SelectItem>
<SelectItem value="debug">debug</SelectItem>
<SelectItem value="info">info</SelectItem>
<SelectItem value="warn">warn</SelectItem>
<SelectItem value="error">error</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Format">
<Select value={logFormat} onValueChange={setLogFormat} disabled={!canEdit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">text</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="size-4 text-emerald-400" />
Health checks
</CardTitle>
<CardDescription>Runtime probes on the Headscale instance.</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<HealthRow ok label="gRPC listener" />
<HealthRow ok label="Database" />
<HealthRow ok label="OIDC issuer" />
<HealthRow ok={false} label="DERP fallback" />
</CardContent>
</Card>
</div>
);
}
function HealthRow({ ok, label }: { ok: boolean; label: string }) {
return (
<div className="flex items-center justify-between rounded-md border border-input bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}</span>
{ok ? (
<span className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="size-4" />
OK
</span>
) : (
<span className="flex items-center gap-1 text-destructive">
<XCircle className="size-4" />
Degraded
</span>
)}
</div>
);
}

View File

@ -0,0 +1,317 @@
// 🤠 Heady App Shell — React replacement for the Alpine-driven Layout chrome.
//
// One component owns: top nav, user menu, auth gating, and the page slot.
// Fetches /api/auth/profile on mount, gates content behind authorization,
// passes the loaded user down to the page via render prop.
import {
Activity,
Bell,
GitBranch,
Globe,
LayoutDashboard,
LogOut,
Network,
Server,
Settings,
Shield,
Terminal,
Users,
} from 'lucide-react';
import * as React from 'react';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
export interface SessionUser {
email: string;
name: string;
role: string;
role_description: string;
picture?: string;
groups: string[];
capabilities: string[];
}
type AuthState =
| { status: 'loading' }
| { status: 'unauthenticated' }
| { status: 'unauthorized'; user: SessionUser }
| { status: 'authenticated'; user: SessionUser };
const NAV_ITEMS: { href: string; label: string; icon: React.ReactNode }[] = [
{ href: '/', label: 'Dashboard', icon: <LayoutDashboard className="size-4" /> },
{ href: '/machines', label: 'Machines', icon: <Server className="size-4" /> },
{ href: '/terminal', label: 'Terminal', icon: <Terminal className="size-4" /> },
{ href: '/acls', label: 'ACLs', icon: <Shield className="size-4" /> },
{ href: '/dns', label: 'DNS', icon: <Globe className="size-4" /> },
{ href: '/users', label: 'Users', icon: <Users className="size-4" /> },
{ href: '/settings', label: 'Settings', icon: <Settings className="size-4" /> },
];
interface AppShellProps {
currentPath?: string;
requiredRole?: string;
/** Render-prop receives the authenticated user. */
children: (user: SessionUser) => React.ReactNode;
}
export function AppShell({
currentPath = typeof window !== 'undefined' ? window.location.pathname : '/',
requiredRole = 'member',
children,
}: AppShellProps) {
const [auth, setAuth] = React.useState<AuthState>({ status: 'loading' });
React.useEffect(() => {
(async () => {
try {
const r = await fetch('/api/auth/profile');
const data = await r.json();
if (!data.authenticated || !data.user) {
setAuth({ status: 'unauthenticated' });
return;
}
const user = data.user as SessionUser;
const hierarchy = [
'owner',
'admin',
'network_admin',
'it_admin',
'auditor',
'member',
];
const userIdx = hierarchy.indexOf(user.role);
const reqIdx = hierarchy.indexOf(requiredRole);
if (userIdx < 0 || reqIdx < 0 || userIdx > reqIdx) {
setAuth({ status: 'unauthorized', user });
return;
}
setAuth({ status: 'authenticated', user });
} catch (err) {
console.error('Auth check failed', err);
setAuth({ status: 'unauthenticated' });
}
})();
}, [requiredRole]);
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
window.location.href = '/login';
}
}
return (
<div className="min-h-screen bg-background text-foreground">
<TopNav
currentPath={currentPath}
user={auth.status === 'authenticated' ? auth.user : null}
onLogout={logout}
/>
<main className="container py-8">
{auth.status === 'loading' && <AuthLoading />}
{auth.status === 'unauthenticated' && <AuthRequired />}
{auth.status === 'unauthorized' && (
<AccessDenied
user={auth.user}
requiredRole={requiredRole}
onLogout={logout}
/>
)}
{auth.status === 'authenticated' && children(auth.user)}
</main>
</div>
);
}
/* ─────────────────────────────────────────── top nav ─────────── */
function TopNav({
currentPath,
user,
onLogout,
}: {
currentPath: string;
user: SessionUser | null;
onLogout: () => void;
}) {
return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center gap-6">
<a href="/" className="flex items-center gap-2 font-semibold">
<span className="text-xl">🤠</span>
<span className="hidden sm:inline">Heady</span>
</a>
<nav className="hidden md:flex items-center gap-1 text-sm">
{NAV_ITEMS.map((item) => {
const active =
item.href === '/'
? currentPath === '/'
: currentPath.startsWith(item.href);
return (
<a
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-1.5 transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)}
>
{item.icon}
{item.label}
</a>
);
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="icon" aria-label="Notifications">
<Bell className="size-4" />
</Button>
{user && <UserMenu user={user} onLogout={onLogout} />}
</div>
</div>
</header>
);
}
function UserMenu({
user,
onLogout,
}: {
user: SessionUser;
onLogout: () => void;
}) {
const initials = user.name
.split(' ')
.map((p) => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center gap-2 px-2"
>
<Avatar className="size-7">
{user.picture && <AvatarImage src={user.picture} alt={user.name} />}
<AvatarFallback className="text-xs">{initials || '?'}</AvatarFallback>
</Avatar>
<div className="hidden lg:flex flex-col items-start text-xs leading-tight">
<span className="font-medium">{user.email}</span>
<span className="text-muted-foreground">{user.role}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/settings">
<Settings />
Settings
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/users">
<Users />
Users
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onLogout}>
<LogOut />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
/* ─────────────────────────────────────────── auth states ─────── */
function AuthLoading() {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-muted-foreground">
<Activity className="size-6 animate-pulse" />
<p className="text-sm">Loading Heady</p>
</div>
);
}
function AuthRequired() {
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-4 py-24 text-center">
<Shield className="size-12 text-primary" />
<h1 className="text-2xl font-semibold">Authentication required</h1>
<p className="text-sm text-muted-foreground">
Sign in with Authentik to manage your Headscale deployment.
</p>
<Button
size="lg"
onClick={() => {
window.location.href = '/api/auth/login';
}}
>
<GitBranch />
Sign in with Authentik
</Button>
</div>
);
}
function AccessDenied({
user,
requiredRole,
onLogout,
}: {
user: SessionUser;
requiredRole: string;
onLogout: () => void;
}) {
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-4 py-24 text-center">
<Network className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold">Access denied</h1>
<p className="text-sm text-muted-foreground">
This page requires the <Badge variant="outline">{requiredRole}</Badge>{' '}
role. You currently have{' '}
<Badge variant="outline">{user.role}</Badge>.
</p>
<div className="flex gap-2">
<Button variant="outline" onClick={() => (window.location.href = '/')}>
Back to dashboard
</Button>
<Button variant="destructive" onClick={onLogout}>
Sign out
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,37 @@
import { type VariantProps, cva } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
warning:
'border-transparent bg-amber-500/15 text-amber-400 hover:bg-amber-500/20',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,53 @@
// shadcn-ui (new-york) Button primitive — verbatim from the registry.
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,82 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow',
className,
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,143 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName =
DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,147 @@
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,28 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,26 @@
import * as SwitchPrimitives from '@radix-ui/react-switch';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,52 @@
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-4 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,303 @@
// 🤠 Heady Users page — React + shadcn rebuild.
import {
Mail,
MoreHorizontal,
Search,
Shield,
UserPlus,
Users as UsersIcon,
} from 'lucide-react';
import * as React from 'react';
import { AppShell, type SessionUser } from '@/components/shell/AppShell';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type Role =
| 'owner'
| 'admin'
| 'network_admin'
| 'it_admin'
| 'auditor'
| 'member';
export interface UserRow {
id: string;
email: string;
name?: string;
preferred_username: string;
role: Role;
groups: string[];
picture?: string;
last_login?: string;
}
export interface UsersPageProps {
users: UserRow[];
}
export function UsersPage({ users }: UsersPageProps) {
return (
<AppShell currentPath="/users" requiredRole="admin">
{(me) => <UsersBody me={me} users={users} />}
</AppShell>
);
}
function UsersBody({ users, me }: UsersPageProps & { me: SessionUser }) {
const [search, setSearch] = React.useState('');
const [roleFilter, setRoleFilter] = React.useState<string>('all');
const filtered = React.useMemo(() => {
const needle = search.trim().toLowerCase();
return users.filter((u) => {
if (roleFilter !== 'all' && u.role !== roleFilter) return false;
if (!needle) return true;
return (
u.email.toLowerCase().includes(needle) ||
(u.name ?? '').toLowerCase().includes(needle) ||
u.preferred_username.toLowerCase().includes(needle) ||
u.groups.some((g) => g.toLowerCase().includes(needle))
);
});
}, [users, search, roleFilter]);
const roleBreakdown = React.useMemo(() => {
const acc: Partial<Record<Role, number>> = {};
users.forEach((u) => {
acc[u.role] = (acc[u.role] ?? 0) + 1;
});
return acc;
}, [users]);
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">Users</h1>
<p className="mt-1 text-sm text-muted-foreground">
People in your tailnet, with roles mapped from Authentik groups.
</p>
</div>
<Button>
<UserPlus />
Invite User
</Button>
</header>
{/* Role summary cards */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-6">
{(
[
{ role: 'owner', label: 'Owners' },
{ role: 'admin', label: 'Admins' },
{ role: 'network_admin', label: 'Network' },
{ role: 'it_admin', label: 'IT' },
{ role: 'auditor', label: 'Auditors' },
{ role: 'member', label: 'Members' },
] as { role: Role; label: string }[]
).map(({ role, label }) => (
<Card key={role}>
<CardContent className="p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-xl font-semibold leading-tight">
{roleBreakdown[role] ?? 0}
</p>
</CardContent>
</Card>
))}
</section>
{/* Filter bar */}
<Card>
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Search by email, name, username, or group…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All roles</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="network_admin">Network admin</SelectItem>
<SelectItem value="it_admin">IT admin</SelectItem>
<SelectItem value="auditor">Auditor</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Results card */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Directory</CardTitle>
<CardDescription>
{filtered.length} of {users.length} shown
</CardDescription>
</div>
<UsersIcon className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent className="p-0">
{filtered.length === 0 ? (
<EmptyResult />
) : (
<ul className="divide-y divide-border">
{filtered.map((u) => (
<UserRowItem key={u.id} user={u} isMe={u.email === me.email} />
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
function UserRowItem({ user, isMe }: { user: UserRow; isMe: boolean }) {
const initials = (user.name ?? user.preferred_username ?? user.email)
.split(/[\s.@]/)
.map((p) => p[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase();
return (
<li className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-secondary/20">
<Avatar>
{user.picture && <AvatarImage src={user.picture} alt={user.name} />}
<AvatarFallback className="text-xs">{initials || '?'}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate font-medium">{user.name ?? user.preferred_username}</p>
{isMe && (
<Badge variant="outline" className="text-[10px]">
you
</Badge>
)}
</div>
<p className="flex items-center gap-1.5 truncate text-xs text-muted-foreground">
<Mail className="size-3" />
{user.email}
</p>
</div>
<RoleBadge role={user.role} />
<div className="hidden gap-1 md:flex">
{user.groups.slice(0, 2).map((g) => (
<Badge key={g} variant="outline" className="font-mono text-[10px]">
{g}
</Badge>
))}
{user.groups.length > 2 && (
<Badge variant="outline" className="text-[10px]">
+{user.groups.length - 2}
</Badge>
)}
</div>
<UserRowMenu user={user} />
</li>
);
}
function RoleBadge({ role }: { role: Role }) {
const variants: Record<
Role,
{ label: string; className: string }
> = {
owner: { label: 'Owner', className: 'bg-violet-500/15 text-violet-300' },
admin: { label: 'Admin', className: 'bg-rose-500/15 text-rose-300' },
network_admin: {
label: 'Network',
className: 'bg-sky-500/15 text-sky-300',
},
it_admin: { label: 'IT', className: 'bg-emerald-500/15 text-emerald-300' },
auditor: { label: 'Auditor', className: 'bg-amber-500/15 text-amber-300' },
member: { label: 'Member', className: 'bg-muted text-muted-foreground' },
};
const v = variants[role];
return (
<Badge
variant="outline"
className={`border-transparent ${v.className}`}
>
<Shield className="mr-1 size-3" />
{v.label}
</Badge>
);
}
function UserRowMenu({ user }: { user: UserRow }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Row actions">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="truncate">{user.email}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={`/users/${user.id}`}>Details</a>
</DropdownMenuItem>
<DropdownMenuItem>Change role</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">
Disable
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function EmptyResult() {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center text-muted-foreground">
<UsersIcon className="size-8" />
<p className="font-medium text-foreground">No matching users</p>
<p className="text-sm">Try adjusting your search or role filter.</p>
</div>
);
}

View File

@ -10,11 +10,18 @@ import '../styles/global.css';
export interface Props {
title: string;
description?: string;
/**
* Pages built on the React shadcn shell should set `hideChrome={true}` so
* the legacy Alpine nav/footer/toast container doesn't double up over the
* React top nav. Pages still using Alpine keep the default (false).
*/
hideChrome?: boolean;
}
const {
title,
description = "Strategic VPN management that's actually awesome to use!",
hideChrome = false,
} = Astro.props;
---
@ -216,7 +223,11 @@ const {
</style>
</head>
<body class="h-full bg-gray-900 text-white" x-data="headyApp()" x-init="init()">
<body class={hideChrome ? "h-full bg-background text-foreground dark" : "h-full bg-gray-900 text-white"} x-data={hideChrome ? undefined : "headyApp()"} x-init={hideChrome ? undefined : "init()"}>
{hideChrome ? (
<slot />
) : (
<Fragment>
<!-- Navigation -->
<nav class="bg-gray-800 border-b border-gray-700" x-data="navigation()">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
@ -428,5 +439,7 @@ const {
registered before Alpine.js loads. Do not redefine them here — the
non-inline bundled script would overwrite them after Alpine has
already wired up handlers, causing the two copies to drift. -->
</Fragment>
)}
</body>
</html>

View File

@ -1,20 +1,45 @@
// 🔐 OIDC Client - Authentik protocol handler with PKCE support
// 🔐 OIDC client — thin wrapper around openid-client v6.
//
// This module replaces a ~290 LOC hand-rolled OIDC implementation. The
// upstream library handles discovery, PKCE, state, *and ID token signature
// verification* (issuer/audience/exp/JWKS rotation) — the latter is the
// security gap we couldn't address with the hand-rolled code without
// reinventing JWKS handling.
import type { AuthentikConfig } from '../config/authentik.ts';
import * as client from 'openid-client';
import type { AuthentikConfig } from '../config/authentik.js';
export interface PKCEChallenge {
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: 'S256';
let _config: client.Configuration | null = null;
let _configKey: string | null = null;
/**
* Lazily resolve & cache the openid-client Configuration object.
* Discovery is done once per (issuer, client_id) pair; switching either
* invalidates the cache.
*/
export async function getOidcConfig(
config: AuthentikConfig,
): Promise<client.Configuration> {
const key = `${config.issuer}|${config.clientId}`;
if (_config && _configKey === key) {
return _config;
}
const issuer = new URL(config.issuer);
_config = await client.discovery(
issuer,
config.clientId,
config.clientSecret,
// undefined → use ClientSecretPost (the default Authentik expects)
);
_configKey = key;
return _config;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
scope: string;
/** Clear the cached configuration (tests, manual recovery). */
export function clearOidcCache(): void {
_config = null;
_configKey = null;
}
export interface UserInfo {
@ -28,297 +53,5 @@ export interface UserInfo {
email_verified?: boolean;
}
export interface DiscoveryDocument {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
end_session_endpoint?: string;
scopes_supported: string[];
response_types_supported: string[];
code_challenge_methods_supported: string[];
}
/**
* Authentik OIDC Client with PKCE support and auto-discovery
*/
export class AuthentikOIDCClient {
private config: AuthentikConfig;
private discoveryCache: DiscoveryDocument | null = null;
constructor(config: AuthentikConfig) {
// Validate configuration
if (!config.clientId || config.clientId.length < 3) {
throw new Error(
'Invalid OIDC configuration: clientId must be at least 3 characters',
);
}
if (!config.clientSecret || config.clientSecret.length < 10) {
throw new Error(
'Invalid OIDC configuration: clientSecret must be at least 10 characters',
);
}
try {
new URL(config.issuer);
} catch {
throw new Error('Invalid issuer URL');
}
this.config = config;
}
/**
* Discover OIDC endpoints from well-known configuration
*/
async discover(): Promise<DiscoveryDocument> {
if (this.discoveryCache) {
return this.discoveryCache;
}
try {
const response = await fetch(this.config.enhanced.wellKnownUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch OIDC discovery document: ${response.status} ${response.statusText}`,
);
}
const discovery = await response.json();
// Validate required endpoints
if (
!discovery.authorization_endpoint ||
!discovery.token_endpoint ||
!discovery.userinfo_endpoint
) {
throw new Error(
'Invalid discovery document: missing required endpoints',
);
}
this.discoveryCache = discovery;
return discovery;
} catch (error) {
if (
error instanceof Error &&
error.message.includes('discovery document')
) {
throw error;
}
throw new Error(`Failed to fetch OIDC discovery document: ${error}`);
}
}
/**
* Generate PKCE code verifier and challenge
*/
async generatePKCE(): Promise<PKCEChallenge> {
// Generate code verifier (43-128 characters, URL-safe)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Generate code challenge (SHA256 hash of verifier, base64url encoded)
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256',
};
}
/**
* Build authorization URL with PKCE parameters
*/
buildAuthorizationUrl(state: string, pkce: PKCEChallenge): string {
const authUrl = new URL(this.config.enhanced.authorizationEndpoint);
authUrl.searchParams.set('client_id', this.config.clientId);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('redirect_uri', this.config.redirectUri);
authUrl.searchParams.set('scope', this.config.scopes.join(' '));
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', pkce.codeChallenge);
authUrl.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
return authUrl.toString();
}
/**
* Exchange authorization code for tokens using PKCE
*/
async exchangeCodeForTokens(
code: string,
codeVerifier: string,
): Promise<TokenResponse> {
// Ensure we have discovery document
await this.getDiscoveryDocument();
const tokenUrl = this.config.enhanced.tokenEndpoint;
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code_verifier: codeVerifier,
});
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMsg = errorData.error || `HTTP ${response.status}`;
const errorDesc = errorData.error_description || response.statusText;
throw new Error(`Token exchange failed: ${errorMsg} - ${errorDesc}`);
}
return await response.json();
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Token exchange failed')
) {
throw error;
}
throw new Error(`Token exchange failed: ${error}`);
}
}
/**
* Fetch user information using access token
*/
async fetchUserInfo(accessToken: string): Promise<UserInfo> {
// Ensure we have discovery document
await this.getDiscoveryDocument();
const userInfoUrl = this.config.enhanced.userInfoEndpoint;
try {
const response = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch user info: ${response.status} ${response.statusText}`,
);
}
const userInfo = await response.json();
// Ensure groups is always an array
if (!Array.isArray(userInfo.groups)) {
userInfo.groups = [];
}
return userInfo;
} catch (error) {
if (
error instanceof Error &&
error.message.includes('Failed to fetch user info')
) {
throw error;
}
throw new Error(`Failed to fetch user info: ${error}`);
}
}
/**
* Build logout URL for OIDC end session endpoint.
* Prefers the discovery document's end_session_endpoint (which Authentik
* publishes correctly) and falls back to the convention-built URL only if
* discovery hasn't been fetched yet or omitted it.
*/
buildLogoutUrl(postLogoutRedirectUri?: string): string {
const endpoint =
this.discoveryCache?.end_session_endpoint ??
this.config.enhanced.endSessionEndpoint;
const logoutUrl = new URL(endpoint);
if (postLogoutRedirectUri) {
logoutUrl.searchParams.set(
'post_logout_redirect_uri',
postLogoutRedirectUri,
);
}
return logoutUrl.toString();
}
/**
* Generate cryptographically secure state parameter
*/
generateState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Validate state parameter format
*/
validateState(state: string): boolean {
// State should be URL-safe base64 without spaces or special chars
const validPattern = /^[A-Za-z0-9\-_]+$/;
return state.length > 0 && validPattern.test(state);
}
/**
* Get discovery document (internal helper that handles caching and errors)
*/
private async getDiscoveryDocument(): Promise<DiscoveryDocument> {
if (!this.discoveryCache) {
try {
await this.discover();
} catch (error) {
console.error('Discovery document error:', error);
throw error;
}
}
if (!this.discoveryCache) {
throw new Error('Failed to load discovery document');
}
// Validate required endpoints
if (!this.discoveryCache.authorization_endpoint) {
throw new Error('Missing required endpoint: authorization_endpoint');
}
return this.discoveryCache;
}
/**
* Clear cached discovery document (useful for testing or recovery)
*/
clearCache(): void {
this.discoveryCache = null;
}
}
/** Re-export the relevant openid-client functions so endpoints don't import twice. */
export { client };

View File

@ -21,6 +21,8 @@ const COOKIE_MAX_AGE_SECONDS = 10 * 60; // 10 minutes — matches OAuth state TT
interface OidcStatePayload {
state: string;
codeVerifier: string;
/** Optional nonce — required for ID token replay protection. */
nonce?: string;
}
/**
@ -40,6 +42,7 @@ export async function storeOidcState(
const token = await new SignJWT({
state: payload.state,
cv: payload.codeVerifier,
...(payload.nonce ? { n: payload.nonce } : {}),
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
@ -80,11 +83,16 @@ export async function consumeOidcState(
const state = payload.state;
const codeVerifier = payload.cv;
const nonce = payload.n;
if (typeof state !== 'string' || typeof codeVerifier !== 'string') {
return null;
}
return { state, codeVerifier };
return {
state,
codeVerifier,
...(typeof nonce === 'string' ? { nonce } : {}),
};
} catch (error) {
console.warn('OIDC state cookie verification failed:', error);
return null;

View File

@ -1,6 +1,13 @@
// 🔐 Session Manager - Secure HTTP-only cookie session management
// 🔐 Session Manager - Secure HTTP-only cookie session management.
//
// Storage is pluggable via the SessionStore interface. Auto-selects Redis
// when REDIS_URL is set, falls back to an in-memory Map otherwise. The
// in-memory store is fine for unit tests and single-node dev but does NOT
// survive Vite HMR / process restart, and is invisible to other workers in
// a cluster — production deployments must point REDIS_URL at a real Redis.
import type { APIContext } from 'astro';
import Redis from 'ioredis';
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
import { getSessionConfig } from '../config/authentik.js';
@ -51,54 +58,241 @@ export interface SessionCleanupResult {
error_details?: string[];
}
/**
* Secure session manager with HTTP-only cookies and in-memory storage
* In production, this should be backed by a persistent store (Redis, Database)
*/
export class HeadySessionManager {
private sessions = new Map<string, SessionData>();
private config = getSessionConfig();
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
/** Internal session record. */
interface SessionData {
user: SessionUser;
created_at: string;
last_activity: string;
expires_at: string;
ip_address: string;
}
constructor() {
// Cleanup expired sessions every 5 minutes.
// unref() lets the Node process exit even if the timer is still
// scheduled — without this, tests and short-lived scripts hang.
this.cleanupTimer = setInterval(
() => {
this.cleanupExpiredSessions().catch(console.error);
},
5 * 60 * 1000,
);
if (typeof this.cleanupTimer === 'object' && this.cleanupTimer && 'unref' in this.cleanupTimer) {
(this.cleanupTimer as { unref: () => void }).unref();
/* ────────────────────────────────────────────── Session storage ──── */
/**
* Storage backend interface. All ops are async so Redis implementations
* are natural; in-memory wraps Map operations in Promise.resolve.
*/
interface SessionStore {
get(sessionId: string): Promise<SessionData | null>;
/** ttlSeconds is the absolute expiration delta; the store should honor it. */
set(sessionId: string, data: SessionData, ttlSeconds: number): Promise<void>;
delete(sessionId: string): Promise<boolean>;
/** Size hint for monitoring. May be approximate (Redis SCAN sampled). */
size(): Promise<number>;
/**
* Sweep expired entries. Redis handles TTL automatically so this is a
* no-op there; in-memory implementations need to walk the Map.
*/
cleanup(now: Date): Promise<SessionCleanupResult>;
/** Shut down any background work (timers, Redis client). */
dispose(): Promise<void>;
}
/** In-memory store. Used for tests and dev when REDIS_URL isn't set. */
class InMemorySessionStore implements SessionStore {
private map = new Map<string, SessionData>();
async get(sessionId: string): Promise<SessionData | null> {
return this.map.get(sessionId) ?? null;
}
async set(
sessionId: string,
data: SessionData,
_ttlSeconds: number,
): Promise<void> {
// TTL is enforced via the expires_at field in the record; cleanup()
// walks the Map periodically.
this.map.set(sessionId, data);
}
async delete(sessionId: string): Promise<boolean> {
return this.map.delete(sessionId);
}
async size(): Promise<number> {
return this.map.size;
}
async cleanup(now: Date): Promise<SessionCleanupResult> {
let cleaned = 0;
const errorDetails: string[] = [];
for (const [id, data] of this.map.entries()) {
if (now > new Date(data.expires_at)) {
try {
this.map.delete(id);
cleaned++;
} catch (err) {
errorDetails.push(`Failed to delete ${id.slice(0, 8)}: ${err}`);
}
}
}
return {
cleaned,
errors: errorDetails.length,
error_details: errorDetails.length ? errorDetails : undefined,
};
}
async dispose(): Promise<void> {
this.map.clear();
}
}
/** Redis store. Uses one key per session with native EXPIRE. */
class RedisSessionStore implements SessionStore {
private client: Redis;
private prefix = 'heady:sess:';
constructor(url: string) {
this.client = new Redis(url, {
// Don't keep reconnecting forever on misconfigured URLs in dev —
// fail loud after a few attempts so the operator notices.
maxRetriesPerRequest: 3,
lazyConnect: false,
});
this.client.on('error', (err) => {
console.error('Redis session store error:', err.message);
});
this.client.on('connect', () => {
console.log(`✓ Redis session store connected at ${url}`);
});
}
private key(sessionId: string): string {
return `${this.prefix}${sessionId}`;
}
async get(sessionId: string): Promise<SessionData | null> {
const raw = await this.client.get(this.key(sessionId));
if (!raw) return null;
try {
return JSON.parse(raw) as SessionData;
} catch {
// Corrupted value — best to drop it.
await this.client.del(this.key(sessionId));
return null;
}
}
/**
* Stop the cleanup interval. Call this when disposing the manager
* (e.g. in tests or on graceful shutdown).
*/
dispose(): void {
async set(
sessionId: string,
data: SessionData,
ttlSeconds: number,
): Promise<void> {
// SET ... EX <ttl> atomically writes and sets the expiration so we
// don't have to clean up by hand.
await this.client.set(
this.key(sessionId),
JSON.stringify(data),
'EX',
ttlSeconds,
);
}
async delete(sessionId: string): Promise<boolean> {
const count = await this.client.del(this.key(sessionId));
return count > 0;
}
async size(): Promise<number> {
// SCAN to avoid blocking on KEYS in production. We accept that this
// is approximate under concurrent writes.
let cursor = '0';
let count = 0;
do {
const [next, batch] = await this.client.scan(
cursor,
'MATCH',
`${this.prefix}*`,
'COUNT',
100,
);
cursor = next;
count += batch.length;
} while (cursor !== '0');
return count;
}
async cleanup(_now: Date): Promise<SessionCleanupResult> {
// Redis TTL handles expiration natively — nothing for us to do here.
return { cleaned: 0, errors: 0 };
}
async dispose(): Promise<void> {
this.client.disconnect();
}
}
/* ────────────────────────────────────────── HeadySessionManager ──── */
/**
* Secure session manager with HTTP-only signed cookies.
* Sessions are stored in a SessionStore (Redis in production, in-memory
* for dev/tests). Cookies carry a signed JWT containing the session ID;
* the server looks the rest of the record up in the store.
*/
export class HeadySessionManager {
private config = getSessionConfig();
private store: SessionStore;
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
private storeKind: 'redis' | 'memory';
constructor(store?: SessionStore) {
if (store) {
this.store = store;
this.storeKind = store instanceof RedisSessionStore ? 'redis' : 'memory';
} else {
const redisUrl = process.env.REDIS_URL;
if (redisUrl) {
this.store = new RedisSessionStore(redisUrl);
this.storeKind = 'redis';
} else {
this.store = new InMemorySessionStore();
this.storeKind = 'memory';
console.warn(
'⚠️ Using in-memory session store. Sessions WILL be lost on restart. Set REDIS_URL for production.',
);
}
}
// Only the in-memory store needs a sweeper. Redis handles TTL itself.
if (this.storeKind === 'memory') {
this.cleanupTimer = setInterval(
() => {
this.store.cleanup(new Date()).catch(console.error);
},
5 * 60 * 1000,
);
if (
typeof this.cleanupTimer === 'object' &&
this.cleanupTimer &&
'unref' in this.cleanupTimer
) {
(this.cleanupTimer as { unref: () => void }).unref();
}
}
}
/** Stop the cleanup timer and release the store. */
async dispose(): Promise<void> {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
await this.store.dispose();
}
/**
* Create a new session with HTTP-only secure cookie
*/
/** Create a new session, write the cookie, return the result. */
async createSession(
context: APIContext,
user: SessionUser,
): Promise<SessionCreationResult> {
// Generate cryptographically secure session ID
const sessionId = this.generateSessionId();
const now = new Date();
const expiresAt = new Date(now.getTime() + this.config.lifetime);
// Create session data
const sessionData: SessionData = {
user,
created_at: now.toISOString(),
@ -107,13 +301,13 @@ export class HeadySessionManager {
ip_address: context.clientAddress,
};
// Store session in memory (in production, use persistent store)
this.sessions.set(sessionId, sessionData);
await this.store.set(
sessionId,
sessionData,
Math.floor(this.config.lifetime / 1000),
);
// Create signed session token (HMAC-SHA256 JWT)
const sessionToken = await this.signSessionToken(sessionId);
// Set HTTP-only secure cookie
context.cookies.set(this.config.cookieName, sessionToken, {
httpOnly: this.config.httpOnly,
secure: this.config.secure,
@ -123,7 +317,7 @@ export class HeadySessionManager {
});
console.log(
`✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...)`,
`✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...) [store=${this.storeKind}]`,
);
return {
@ -141,38 +335,38 @@ export class HeadySessionManager {
};
}
/**
* Validate existing session from cookie
*/
async validateSession(context: APIContext): Promise<SessionValidationResult> {
/** Validate the request's session cookie. */
async validateSession(
context: APIContext,
): Promise<SessionValidationResult> {
const sessionCookie = context.cookies.get(this.config.cookieName);
if (!sessionCookie) {
return { valid: false, user: null, reason: 'missing_cookie' };
}
try {
// Verify signed session token to get session ID
const sessionId = await this.verifySessionToken(sessionCookie.value);
// Check if session exists in store
const storedSession = this.sessions.get(sessionId);
const storedSession = await this.store.get(sessionId);
if (!storedSession) {
return { valid: false, user: null, reason: 'session_not_found' };
}
// Check if session is expired
const now = new Date();
const expiresAt = new Date(storedSession.expires_at);
if (now > expiresAt) {
// Clean up expired session
this.sessions.delete(sessionId);
await this.store.delete(sessionId);
return { valid: false, user: null, reason: 'expired' };
}
// Update last activity
// Bump last_activity and write back. Resets the TTL too so an
// active user doesn't get logged out on the sliding window
// expiration of the configured lifetime.
storedSession.last_activity = now.toISOString();
console.log(`✓ Session validated for ${storedSession.user.email}`);
await this.store.set(
sessionId,
storedSession,
Math.max(1, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)),
);
return {
valid: true,
@ -191,9 +385,7 @@ export class HeadySessionManager {
}
}
/**
* Refresh session expiration time
*/
/** Extend session lifetime + reissue cookie. */
async refreshSession(context: APIContext): Promise<SessionRefreshResult> {
const validation = await this.validateSession(context);
if (!validation.valid || !validation.user) {
@ -207,7 +399,7 @@ export class HeadySessionManager {
}
const sessionId = validation.user.session.session_id;
const storedSession = this.sessions.get(sessionId);
const storedSession = await this.store.get(sessionId);
if (!storedSession) {
return {
session_id: sessionId,
@ -218,14 +410,16 @@ export class HeadySessionManager {
};
}
// Extend session lifetime
const now = new Date();
const newExpiresAt = new Date(now.getTime() + this.config.lifetime);
storedSession.expires_at = newExpiresAt.toISOString();
storedSession.last_activity = now.toISOString();
await this.store.set(
sessionId,
storedSession,
Math.floor(this.config.lifetime / 1000),
);
// Update cookie with re-signed token
const sessionToken = await this.signSessionToken(sessionId);
context.cookies.set(this.config.cookieName, sessionToken, {
httpOnly: this.config.httpOnly,
@ -243,82 +437,36 @@ export class HeadySessionManager {
};
}
/**
* Destroy session and clear cookie
*/
async destroySession(context: APIContext): Promise<SessionDestroyResult> {
/** Destroy session and clear the cookie. */
async destroySession(
context: APIContext,
): Promise<SessionDestroyResult> {
const sessionCookie = context.cookies.get(this.config.cookieName);
if (!sessionCookie) {
return { destroyed: false, reason: 'session_not_found' };
}
try {
// Verify signed session token to get session ID
const sessionId = await this.verifySessionToken(sessionCookie.value);
// Remove from store
const existed = this.sessions.delete(sessionId);
// Clear cookie
const existed = await this.store.delete(sessionId);
context.cookies.delete(this.config.cookieName);
console.log(`✓ Session destroyed: ${sessionId.slice(0, 8)}...`);
return { destroyed: existed };
} catch (error) {
// Clear cookie anyway
} catch {
context.cookies.delete(this.config.cookieName);
return { destroyed: false, reason: 'malformed_session' };
}
}
/**
* Clean up expired sessions
*/
/** Sweep expired sessions. Backend-dependent semantics. */
async cleanupExpiredSessions(): Promise<SessionCleanupResult> {
const now = new Date();
let cleaned = 0;
let errors = 0;
const errorDetails: string[] = [];
try {
for (const [sessionId, sessionData] of this.sessions.entries()) {
const expiresAt = new Date(sessionData.expires_at);
if (now > expiresAt) {
try {
this.sessions.delete(sessionId);
cleaned++;
} catch (error) {
errors++;
errorDetails.push(
`Failed to delete session ${sessionId.slice(0, 8)}: ${error}`,
);
}
}
}
if (cleaned > 0) {
console.log(`✓ Cleaned up ${cleaned} expired sessions`);
}
return {
cleaned,
errors,
error_details: errorDetails.length > 0 ? errorDetails : undefined,
};
} catch (error) {
console.error('Session cleanup error:', error);
return {
cleaned,
errors: errors + 1,
error_details: [`Cleanup failed: ${error}`],
};
}
return this.store.cleanup(new Date());
}
async getActiveCount(): Promise<number> {
return this.store.size();
}
/**
* Generate cryptographically secure session ID
*/
private generateSessionId(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
@ -328,10 +476,6 @@ export class HeadySessionManager {
);
}
/**
* Sign session token using HMAC-SHA256 JWT
* Provides cryptographic integrity (tamper-evident) and configurable expiration
*/
private async signSessionToken(sessionId: string): Promise<string> {
const secret = new TextEncoder().encode(this.config.secret);
const expirationSeconds = Math.floor(
@ -345,27 +489,18 @@ export class HeadySessionManager {
.sign(secret);
}
/**
* Verify signed session token using HMAC-SHA256 JWT
* Uses constant-time comparison internally to prevent timing attacks
* Honors the configured session lifetime via JWT exp claim
*/
private async verifySessionToken(token: string): Promise<string> {
const secret = new TextEncoder().encode(this.config.secret);
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
});
const sessionId = payload.sid;
if (typeof sessionId !== 'string' || !sessionId.startsWith('sess_')) {
throw new Error('Invalid session token payload');
}
return sessionId;
} catch (error) {
// jose throws specific error types for expired/invalid tokens
if (error instanceof joseErrors.JWTExpired) {
throw new Error('Session token expired');
}
@ -379,21 +514,11 @@ export class HeadySessionManager {
}
}
/**
* Internal session data structure
*/
interface SessionData {
user: SessionUser;
created_at: string;
last_activity: string;
expires_at: string;
ip_address: string;
}
/* ─────────────────────────────────────────────────── singletons ──── */
/**
* Shared singleton session manager
* All API routes must use this instance to share the same sessions Map.
* Lazily instantiated so import-time config errors don't crash routes.
* Shared singleton across API routes. Lazy so import-time config errors
* don't crash unrelated modules.
*/
let _sessionManager: HeadySessionManager | null = null;
@ -405,10 +530,13 @@ export function getSessionManager(): HeadySessionManager {
}
/**
* Get current active session count from the shared singleton
* Current active session count. Async now because Redis lookups are.
* The legacy sync getActiveSessionCount() callers (status endpoint) need
* to be updated; we keep the old export name pointed at a synchronous
* fallback that returns 0 to avoid a breaking change at import-time.
*/
export function getActiveSessionCount(): number {
if (!_sessionManager) return 0;
return (_sessionManager as unknown as { sessions: Map<string, unknown> })
.sessions.size;
// Backwards-compatible synchronous accessor. New callers should prefer
// getSessionManager().getActiveCount() which returns a Promise<number>.
return 0;
}

10
src/lib/utils.ts Normal file
View File

@ -0,0 +1,10 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* shadcn-ui's classname helper: merges Tailwind classes intelligently
* (later classes override earlier ones for the same property).
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -1,12 +1,17 @@
// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication
/**
* Handles OIDC callback from Authentik
* Exchanges authorization code for tokens, creates session, and redirects to dashboard
*/
// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication.
//
// openid-client.authorizationCodeGrant() does all the heavy lifting:
// • State validation (CSRF)
// • PKCE verification
// • ID token signature verification against the IdP's JWKS
// • iss / aud / exp / nonce checks
// • Returns a TokenSet with verified claims
//
// We then fetch userinfo (with subject pinning to prevent token substitution)
// and create the Heady session.
import type { APIRoute } from 'astro';
import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
import { consumeOidcState } from '../../../lib/auth/oidc-state.js';
import { mapAuthentikGroups } from '../../../lib/auth/role-mapper.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
@ -20,77 +25,75 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Callback received');
// Extract parameters from callback
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// Authentik-side error.
const error = url.searchParams.get('error');
// Handle Authentik error responses.
// Don't reflect Authentik's error_description into the URL — it's
// attacker-controlled if Authentik is compromised/MITM'd.
if (error) {
console.error(`❌ Authentik returned error: ${error}`);
return redirect(`/login?error=${encodeURIComponent(error)}`);
}
// Validate required parameters
if (!code || !state) {
console.error('❌ Missing required parameters in callback');
return redirect('/login?error=invalid_callback');
}
console.log(`✓ Callback parameters received - State: ${state}`);
// Retrieve and verify the signed state cookie. The cookie carries the
// PKCE code_verifier plus the state nonce we set during /api/auth/login.
// This works across any number of workers/replicas because the state
// rides with the browser, not the server.
// Pull the PKCE verifier + state + nonce we stashed during /login.
const stateData = await consumeOidcState(context);
if (!stateData) {
console.error('❌ Missing or invalid state cookie');
return redirect('/login?error=invalid_state');
}
// Compare the state in the cookie with the state from the IdP redirect.
// They must match — this is the CSRF defense for the OAuth dance.
if (stateData.state !== state) {
console.error('❌ State mismatch (CSRF check failed)');
return redirect('/login?error=invalid_state');
}
console.log('✓ State cookie retrieved');
console.log(`✓ State validated and PKCE verifier retrieved`);
// Load Authentik configuration
const config = loadAuthentikConfig();
const oidcConfig = await getOidcConfig(config);
// Create OIDC client
const oidcClient = new AuthentikOIDCClient(config);
// Hand the full current URL (with code + state query params) to
// openid-client. It will:
// - Compare query.state to expectedState
// - Use pkceCodeVerifier to prove possession of the original request
// - Verify the returned ID token's signature against the IdP's JWKS
// - Check iss / aud / exp / nonce claims
console.log(' → Exchanging code for tokens (with ID token verification)...');
const tokens = await client.authorizationCodeGrant(oidcConfig, url, {
pkceCodeVerifier: stateData.codeVerifier,
expectedState: stateData.state,
...(stateData.nonce ? { expectedNonce: stateData.nonce } : {}),
});
// Exchange authorization code for tokens
console.log(' → Exchanging code for tokens...');
const tokens = await oidcClient.exchangeCodeForTokens(
code,
stateData.codeVerifier,
);
const idTokenClaims = tokens.claims();
if (!idTokenClaims) {
throw new Error('Token response missing verified ID token claims');
}
console.log(
`✓ Tokens received (access token: ${tokens.access_token.substring(0, 20)}...)`,
`✓ Tokens received and ID token verified (sub: ${idTokenClaims.sub})`,
);
// Fetch user information from Authentik
console.log(' → Fetching user information...');
const userInfo = await oidcClient.fetchUserInfo(tokens.access_token);
// Fetch userinfo. Pinning expectedSubject prevents token-substitution
// (returning userinfo for a different user than the ID token's sub).
console.log(' → Fetching userinfo...');
const userInfoRaw = await client.fetchUserInfo(
oidcConfig,
tokens.access_token,
idTokenClaims.sub,
);
const userInfo = {
sub: userInfoRaw.sub,
email: (userInfoRaw.email as string) ?? '',
name: (userInfoRaw.name as string) ?? '',
picture: userInfoRaw.picture as string | undefined,
groups: Array.isArray(userInfoRaw.groups)
? (userInfoRaw.groups as string[])
: [],
};
console.log(
`✓ User info received: ${userInfo.email} (${userInfo.groups.length} groups)`,
);
// Map Authentik groups to Heady role
// Map Authentik groups → Heady role.
const roleMapping = mapAuthentikGroups(userInfo.groups);
console.log(`✓ Role mapping completed:`);
console.log(` User groups: ${userInfo.groups.join(', ') || 'none'}`);
console.log(` Mapped role: ${roleMapping.role} (${roleMapping.method})`);
console.log(` Matched group: ${roleMapping.matchedGroup || 'none'}`);
// Create user data for session
const sessionUser = {
email: userInfo.email,
name: userInfo.name,
@ -99,14 +102,9 @@ export const GET: APIRoute = async (context) => {
picture: userInfo.picture,
groups: userInfo.groups,
capabilities: roleMapping.role_mapping.capabilities,
session: {
session_id: '', // Will be filled by session manager
expires_at: '', // Will be filled by session manager
last_activity: '', // Will be filled by session manager
},
session: { session_id: '', expires_at: '', last_activity: '' },
};
// Create secure session
console.log(' → Creating session...');
const sessionMgr = getSessionManager();
const sessionResult = await sessionMgr.createSession(context, sessionUser);
@ -114,19 +112,10 @@ export const GET: APIRoute = async (context) => {
console.log(
`✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`,
);
console.log(` Expires: ${sessionResult.expires_at}`);
console.log(`🎉 Authentication successful for ${userInfo.email}`);
console.log(` Role: ${roleMapping.role}`);
console.log(` Groups: ${userInfo.groups.join(', ') || 'none'}`);
console.log(
` Session expires: ${new Date(sessionResult.expires_at).toLocaleString()}`,
);
// Redirect to dashboard (session cookie was set by session manager)
return redirect('/', 302);
} catch (error) {
// Log full error details server-side for diagnostics.
console.error('❌ OIDC callback error:', error);
if (error instanceof Error) {
console.error(` Error type: ${error.constructor.name}`);
@ -138,16 +127,19 @@ export const GET: APIRoute = async (context) => {
}
}
// Redirect with a SAFE error code only — never reflect raw error.message
// into the URL. Doing so leaks internal details (client_id, hostnames,
// stack hints) to the browser URL bar, history, access logs, and any
// Referer header on subsequent navigations.
// Map known error shapes to safe codes — never reflect error messages.
let errorCode = 'authentication_failed';
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (msg.includes('token exchange')) errorCode = 'token_exchange_failed';
else if (msg.includes('user info')) errorCode = 'user_info_failed';
if (msg.includes('state')) errorCode = 'invalid_state';
else if (msg.includes('nonce')) errorCode = 'invalid_nonce';
else if (msg.includes('pkce') || msg.includes('code_verifier'))
errorCode = 'invalid_pkce';
else if (msg.includes('signature') || msg.includes('jws'))
errorCode = 'invalid_id_token';
else if (msg.includes('discovery')) errorCode = 'discovery_failed';
else if (msg.includes('userinfo')) errorCode = 'user_info_failed';
else if (msg.includes('token')) errorCode = 'token_exchange_failed';
}
return redirect(`/login?error=callback_failed&code=${errorCode}`);

View File

@ -1,12 +1,10 @@
// 🤠 Heady OIDC Login Endpoint - Initiate Authentik Authentication
/**
* Initiates OIDC authentication flow with Authentik
* Creates authorization URL and redirects user to Authentik login
*/
// 🤠 Heady OIDC Login Endpoint - Initiate Authentik Authentication.
//
// Generates PKCE + state + nonce, stashes them in a signed cookie, and
// redirects the user to the OIDC provider's authorization endpoint.
import type { APIRoute } from 'astro';
import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
import { storeOidcState } from '../../../lib/auth/oidc-state.js';
import {
loadAuthentikConfig,
@ -21,10 +19,8 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Login initiated');
// Load and validate Authentik configuration
const config = loadAuthentikConfig();
const validation = validateAuthentikConfig();
if (!validation.valid) {
console.error('❌ Invalid Authentik configuration:', validation.errors);
return new Response(
@ -32,10 +28,7 @@ export const GET: APIRoute = async (context) => {
error: 'Authentication configuration error',
details: validation.errors,
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
@ -45,31 +38,34 @@ export const GET: APIRoute = async (context) => {
console.log(` Redirect URI: ${config.redirectUri}`);
console.log(` Scopes: ${config.scopes.join(', ')}`);
// Create OIDC client
const oidcClient = new AuthentikOIDCClient(config);
// Discover the provider once, then cache.
const oidcConfig = await getOidcConfig(config);
// Generate PKCE challenge
const pkce = await oidcClient.generatePKCE();
// PKCE + state + nonce. openid-client gives us cryptographically-secure
// randoms for each.
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
// Generate state parameter
const state = oidcClient.generateState();
// Stash everything the callback needs in the signed-cookie state store.
// codeVerifier proves we're the original requester, state defends CSRF,
// nonce binds the ID token to this specific login.
await storeOidcState(context, { state, codeVerifier, nonce });
// Store {state, codeVerifier} in a signed cookie so the callback can
// retrieve them regardless of which worker handles it.
await storeOidcState(context, {
const authUrl = client.buildAuthorizationUrl(oidcConfig, {
redirect_uri: config.redirectUri,
scope: config.scopes.join(' '),
state,
codeVerifier: pkce.codeVerifier,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
// Build authorization URL
const authUrl = oidcClient.buildAuthorizationUrl(state, pkce);
console.log(`✓ Redirecting to authorization endpoint`);
console.log(` State: ${state.slice(0, 10)}… Nonce: ${nonce.slice(0, 10)}`);
console.log(`✓ Redirecting to Authentik authorization endpoint`);
console.log(` State: ${state}`);
console.log(` PKCE Challenge: ${pkce.codeChallenge}`);
// Redirect to Authentik for authentication
return redirect(authUrl, 302);
return redirect(authUrl.toString(), 302);
} catch (error) {
console.error('❌ OIDC login error:', error);
@ -84,10 +80,7 @@ export const GET: APIRoute = async (context) => {
'Check network connectivity to Authentik server',
],
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
};

View File

@ -6,7 +6,7 @@
*/
import type { APIRoute } from 'astro';
import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
import { loadAuthentikConfig } from '../../../lib/config/authentik.js';
@ -43,28 +43,19 @@ export const POST: APIRoute = async ({ request, url, cookies }) => {
console.log(' → Initiating full OIDC logout with Authentik');
try {
// Load configuration and create OIDC client for logout URL
// openid-client's buildEndSessionUrl uses the authoritative
// end_session_endpoint published in the discovery document, not
// the convention-built fallback (which may 404 against some IdPs).
const config = loadAuthentikConfig();
const oidcClient = new AuthentikOIDCClient(config);
// Ensure discovery cache is populated so buildLogoutUrl can use
// the authoritative end_session_endpoint from the IdP instead of
// the convention-built fallback (which may be a 404).
await oidcClient.discover();
// Get Authentik logout URL
const logoutUrl = oidcClient.buildLogoutUrl(
`${url.origin}/login?message=logout_successful`,
);
const oidcConfig = await getOidcConfig(config);
const logoutUrl = client.buildEndSessionUrl(oidcConfig, {
post_logout_redirect_uri: `${url.origin}/login?message=logout_successful`,
});
console.log(`✓ Redirecting to Authentik logout: ${logoutUrl}`);
// Redirect to Authentik logout (session cookie was already cleared)
return new Response(null, {
status: 302,
headers: {
Location: logoutUrl,
},
headers: { Location: logoutUrl.toString() },
});
} catch (error) {
console.warn(

View File

@ -6,10 +6,7 @@
*/
import type { APIRoute } from 'astro';
import {
getActiveSessionCount,
getSessionManager,
} from '../../../lib/auth/session-manager.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
import {
getRoleMappingConfig,
loadAuthentikConfig,
@ -104,7 +101,7 @@ export const GET: APIRoute = async ({ url, cookies }) => {
}
// Get session statistics (simplified for now)
const activeSessionCount = getActiveSessionCount();
const activeSessionCount = await sessionMgr.getActiveCount();
const detailedStatus = {
...basicStatus,

View File

@ -1,694 +1,30 @@
---
import { getCollection } from 'astro:content';
// Heady DNS Management - Alpine.js/Astro DNS Configuration 🤠
import Layout from '../layouts/Layout.astro';
// 🌐 Heady DNS — Astro shell, React island.
// Mock DNS configuration data (in production, this would come from Headscale config)
const dnsConfig = {
// Network prefixes from Headscale
import { DnsPage } from '@/components/dns/DnsPage';
import Layout from '@/layouts/Layout.astro';
// Sample DNS config until /api/dns is wired to Headscale.
const initial = {
prefixes: ['100.64.0.0/10', 'fd7a:115c:a1e0::/48'],
// Magic DNS configuration
magicDns: true,
baseDomain: 'heady.local',
// Global nameservers
nameservers: ['1.1.1.1', '8.8.8.8', '9.9.9.9'],
// Split DNS configuration
splitDns: {
'corp.company.com': ['10.0.0.1', '10.0.0.2'],
'corp.example.com': ['10.0.0.1', '10.0.0.2'],
'internal.dev': ['192.168.1.1'],
},
// Search domains
searchDomains: ['company.com', 'dev.local', 'staging.local'],
// Override local DNS
searchDomains: ['example.com', 'dev.local', 'staging.local'],
overrideDns: true,
// Extra DNS records
extraRecords: [
{ name: 'api.heady.local', type: 'A', value: '100.64.0.10' },
{ name: 'db.heady.local', type: 'A', value: '100.64.0.11' },
{ name: 'web.heady.local', type: 'A', value: '100.64.0.12' },
{ name: 'vpn.heady.local', type: 'AAAA', value: 'fd7a:115c:a1e0::1' },
{ name: 'api.heady.local', type: 'A' as const, value: '100.64.0.10' },
{ name: 'db.heady.local', type: 'A' as const, value: '100.64.0.11' },
{ name: 'web.heady.local', type: 'A' as const, value: '100.64.0.12' },
{ name: 'vpn.heady.local', type: 'AAAA' as const, value: 'fd7a:115c:a1e0::1' },
],
// Permissions
access: true, // User can modify DNS settings
writable: true, // Configuration is writable (not read-only)
};
// Get recent DNS-related activity
const recentActivity = await getCollection(
'activity',
({ data }) => data.resource_type === 'dns',
).then((items) =>
items
.slice(0, 5)
.sort(
(a, b) =>
new Date(b.data.timestamp).getTime() -
new Date(a.data.timestamp).getTime(),
),
);
---
<Layout title="DNS Configuration">
<div class="min-h-screen bg-gray-900" x-data="dnsPage()" x-init="init()">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white flex items-center mb-4">
<span class="mr-3">🌐</span>
DNS Configuration
</h1>
<p class="text-gray-300 max-w-4xl">
Configure DNS settings for your Heady network including Magic DNS, custom records,
nameservers, and search domains.
</p>
</div>
<!-- Permission Notices -->
<template x-if="!config.writable">
<div class="mb-6 p-4 bg-yellow-900/50 border border-yellow-700 rounded-lg">
<div class="flex items-center">
<span class="text-yellow-500 text-xl mr-3">⚠️</span>
<div>
<h3 class="text-yellow-200 font-semibold">Read-only Configuration</h3>
<p class="text-yellow-300 text-sm mt-1">
The Headscale configuration is read-only. You cannot make changes to the DNS configuration.
</p>
</div>
</div>
</div>
</template>
<template x-if="!config.access">
<div class="mb-6 p-4 bg-red-900/50 border border-red-700 rounded-lg">
<div class="flex items-center">
<span class="text-red-500 text-xl mr-3">🚫</span>
<div>
<h3 class="text-red-200 font-semibold">Insufficient Permissions</h3>
<p class="text-red-300 text-sm mt-1">
Your permissions do not allow you to modify the DNS settings for this tailnet.
</p>
</div>
</div>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Configuration -->
<div class="lg:col-span-2 space-y-8">
<!-- Tailnet Naming -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🏷️</span>
Tailnet Name
</h2>
<p class="text-gray-300 text-sm mb-4">
The base domain for your tailnet. When Magic DNS is enabled, machines will be accessible
at <code class="bg-gray-700 px-1 rounded">machine.{dnsConfig.baseDomain}</code>
</p>
<div class="flex items-center space-x-3">
<div class="flex-1">
<input
x-model="tailnetName"
:disabled="isDisabled"
type="text"
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-4 py-2 text-white font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
placeholder="tailnet.example.com"
/>
</div>
<button
@click="updateTailnetName()"
:disabled="isDisabled || tailnetName === config.baseDomain || updating"
:class="isDisabled || tailnetName === config.baseDomain || updating
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'"
class="px-4 py-2 rounded-lg font-medium transition-colors"
>
<span x-text="updating ? 'Updating...' : 'Update'"></span>
</button>
</div>
</div>
<!-- Magic DNS -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center">
<span class="mr-2">✨</span>
Magic DNS
</h2>
<p class="text-gray-300 text-sm mb-4">
Automatically register domain names for each device on the tailnet.
Devices will be accessible at <code class="bg-gray-700 px-1 rounded">[device].{dnsConfig.baseDomain}</code>
when Magic DNS is enabled.
</p>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div
@click="!isDisabled && toggleMagicDns()"
:class="config.magicDns
? 'bg-blue-600'
: 'bg-gray-600'"
class="relative w-12 h-6 rounded-full transition-colors cursor-pointer"
:class="isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
>
<div
:class="config.magicDns ? 'translate-x-6' : 'translate-x-1'"
class="absolute top-1 w-4 h-4 bg-white rounded-full transition-transform"
></div>
</div>
<span class="text-white font-medium" x-text="config.magicDns ? 'Enabled' : 'Disabled'"></span>
</div>
<div class="text-sm">
<template x-if="config.magicDns">
<span class="text-green-400">✅ Active</span>
</template>
<template x-if="!config.magicDns">
<span class="text-gray-400">⭕ Inactive</span>
</template>
</div>
</div>
</div>
<!-- Nameservers -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🌍</span>
Nameservers
</h2>
<p class="text-gray-300 text-sm mb-4">
Configure global and split DNS nameservers for your tailnet.
</p>
<!-- Global Nameservers -->
<div class="mb-6">
<h3 class="text-lg font-medium text-white mb-3">Global Nameservers</h3>
<div class="space-y-2">
<template x-for="(ns, index) in config.nameservers" :key="index">
<div class="flex items-center space-x-3">
<input
:value="ns"
@input="updateNameserver(index, $event.target.value)"
:disabled="isDisabled"
type="text"
class="flex-1 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
placeholder="1.1.1.1"
/>
<button
@click="removeNameserver(index)"
:disabled="isDisabled"
class="text-red-400 hover:text-red-300 p-1 disabled:opacity-50"
>
🗑️
</button>
</div>
</template>
<template x-if="!isDisabled">
<button
@click="addNameserver()"
class="w-full border-2 border-dashed border-gray-600 rounded-lg py-2 text-gray-400 hover:text-gray-300 hover:border-gray-500 transition-colors"
>
+ Add Nameserver
</button>
</template>
</div>
</div>
<!-- Split DNS -->
<div>
<h3 class="text-lg font-medium text-white mb-3">Split DNS</h3>
<div class="space-y-3">
<template x-for="[domain, servers] in Object.entries(config.splitDns)" :key="domain">
<div class="bg-gray-900 rounded-lg p-4 border border-gray-600">
<div class="flex items-center justify-between mb-2">
<code class="text-blue-400 font-medium" x-text="domain"></code>
<button
@click="removeSplitDns(domain)"
:disabled="isDisabled"
class="text-red-400 hover:text-red-300 p-1 disabled:opacity-50"
>
🗑️
</button>
</div>
<div class="text-sm text-gray-300 font-mono" x-text="servers.join(', ')"></div>
</div>
</template>
<template x-if="!isDisabled">
<div x-show="showAddSplitDns" class="bg-gray-900 rounded-lg p-4 border border-gray-600">
<div class="space-y-3">
<input
x-model="newSplitDomain"
type="text"
placeholder="example.com"
class="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
x-model="newSplitServers"
type="text"
placeholder="10.0.0.1, 10.0.0.2"
class="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div class="flex space-x-2">
<button
@click="addSplitDns()"
class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
>
Add
</button>
<button
@click="showAddSplitDns = false; newSplitDomain = ''; newSplitServers = ''"
class="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
</div>
<button
x-show="!showAddSplitDns"
@click="showAddSplitDns = true"
class="w-full border-2 border-dashed border-gray-600 rounded-lg py-2 text-gray-400 hover:text-gray-300 hover:border-gray-500 transition-colors"
>
+ Add Split DNS
</button>
</template>
</div>
</div>
</div>
<!-- DNS Records -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center">
<span class="mr-2">📝</span>
Custom DNS Records
</h2>
<p class="text-gray-300 text-sm mb-4">
Add custom DNS records to your tailnet. Only A and AAAA records are currently supported.
<a href="https://headscale.net/stable/ref/dns" target="_blank" class="text-blue-400 hover:text-blue-300 underline ml-1">
Learn More
</a>
</p>
<div class="space-y-3">
<template x-if="config.extraRecords.length === 0">
<div class="text-center py-8 text-gray-400">
<span class="text-2xl mb-2 block">📋</span>
<p>No DNS records found</p>
</div>
</template>
<template x-for="record in config.extraRecords" :key="`${record.name}-${record.value}`">
<div class="flex items-center space-x-3 bg-gray-900 rounded-lg p-3 border border-gray-600">
<div
:class="record.type === 'A' ? 'bg-green-900 text-green-200' : 'bg-blue-900 text-blue-200'"
class="px-2 py-1 rounded text-xs font-mono font-bold min-w-12 text-center"
x-text="record.type"
></div>
<div class="grid grid-cols-2 gap-3 flex-1">
<div class="font-mono text-sm text-white" x-text="record.name"></div>
<div class="font-mono text-sm text-gray-300" x-text="record.value"></div>
</div>
<button
@click="removeRecord(record)"
:disabled="isDisabled"
class="text-red-400 hover:text-red-300 p-1 disabled:opacity-50"
>
🗑️
</button>
</div>
</template>
<!-- Add Record Form -->
<template x-if="!isDisabled">
<div x-show="showAddRecord" class="bg-gray-900 rounded-lg p-4 border border-gray-600">
<div class="grid grid-cols-4 gap-3 mb-3">
<select
x-model="newRecord.type"
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="A">A</option>
<option value="AAAA">AAAA</option>
</select>
<input
x-model="newRecord.name"
type="text"
placeholder="hostname.domain.com"
class="col-span-2 bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
x-model="newRecord.value"
type="text"
:placeholder="newRecord.type === 'A' ? '192.168.1.1' : '2001:db8::1'"
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="flex space-x-2">
<button
@click="addRecord()"
:disabled="!newRecord.name || !newRecord.value"
class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
Add Record
</button>
<button
@click="showAddRecord = false; resetNewRecord()"
class="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
<button
x-show="!showAddRecord"
@click="showAddRecord = true"
class="w-full border-2 border-dashed border-gray-600 rounded-lg py-2 text-gray-400 hover:text-gray-300 hover:border-gray-500 transition-colors"
>
+ Add DNS Record
</button>
</template>
</div>
</div>
<!-- Search Domains -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h2 class="text-xl font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🔍</span>
Search Domains
</h2>
<p class="text-gray-300 text-sm mb-4">
Set custom DNS search domains for your tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<div class="space-y-2">
<!-- Magic DNS domain (locked) -->
<template x-if="config.magicDns">
<div class="flex items-center space-x-3 bg-gray-900 rounded-lg p-3 border border-gray-600">
<span class="text-gray-400">🔒</span>
<span class="font-mono text-sm text-white" x-text="config.baseDomain"></span>
<span class="text-xs text-gray-500 ml-auto">Magic DNS</span>
</div>
</template>
<!-- User-defined search domains -->
<template x-for="(domain, index) in config.searchDomains" :key="domain">
<div class="flex items-center space-x-3 bg-gray-900 rounded-lg p-3 border border-gray-600">
<template x-if="!isDisabled">
<button
@mousedown="startDrag(index)"
class="text-gray-400 hover:text-gray-300 cursor-grab active:cursor-grabbing"
>
⋮⋮
</button>
</template>
<span class="font-mono text-sm text-white flex-1" x-text="domain"></span>
<button
@click="removeSearchDomain(index)"
:disabled="isDisabled"
class="text-red-400 hover:text-red-300 p-1 disabled:opacity-50"
>
🗑️
</button>
</div>
</template>
<!-- Add Search Domain -->
<template x-if="!isDisabled">
<div class="flex items-center space-x-3">
<input
x-model="newSearchDomain"
@keyup.enter="addSearchDomain()"
type="text"
placeholder="example.com"
class="flex-1 bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
@click="addSearchDomain()"
:disabled="!newSearchDomain"
class="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</template>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- DNS Status -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">📊</span>
DNS Status
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Magic DNS:</span>
<span :class="config.magicDns ? 'text-green-400' : 'text-gray-400'">
<span x-text="config.magicDns ? '✅ Enabled' : '⭕ Disabled'"></span>
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Base Domain:</span>
<span class="text-white font-mono text-xs" x-text="config.baseDomain"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Global NS:</span>
<span class="text-white font-mono text-xs" x-text="config.nameservers.length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Split DNS:</span>
<span class="text-white font-mono text-xs" x-text="Object.keys(config.splitDns).length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">DNS Records:</span>
<span class="text-white font-mono text-xs" x-text="config.extraRecords.length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Search Domains:</span>
<span class="text-white font-mono text-xs" x-text="config.searchDomains.length"></span>
</div>
</div>
</div>
<!-- Network Info -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🌐</span>
Network Prefixes
</h3>
<div class="space-y-2">
<template x-for="prefix in config.prefixes" :key="prefix">
<div class="bg-gray-900 rounded px-3 py-2">
<code class="text-xs text-blue-400" x-text="prefix"></code>
</div>
</template>
</div>
</div>
<!-- Recent Changes -->
{recentActivity.length > 0 && (
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🕐</span>
Recent Changes
</h3>
<div class="space-y-3">
{recentActivity.map(activity => (
<div class="flex items-start space-x-3 text-sm">
<div class="w-2 h-2 bg-blue-500 rounded-full mt-2 flex-shrink-0"></div>
<div>
<div class="text-white font-medium">{activity.data.action}</div>
<div class="text-gray-400 text-xs">
{activity.data.user_email} • {new Date(activity.data.timestamp).toLocaleString()}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
<script define:vars={{ dnsConfig }}>
// Initialize config for static rendering
const config = dnsConfig;
function dnsPage() {
return {
// Initialize with server data
config: dnsConfig,
// UI state
updating: false,
tailnetName: dnsConfig.baseDomain,
showAddSplitDns: false,
newSplitDomain: '',
newSplitServers: '',
showAddRecord: false,
newRecord: { type: 'A', name: '', value: '' },
newSearchDomain: '',
// Computed properties
get isDisabled() {
return !this.config.access || !this.config.writable;
},
init() {
console.log('DNS page initialized with config:', this.config);
},
async updateTailnetName() {
if (this.isDisabled || this.tailnetName === this.config.baseDomain) return;
this.updating = true;
try {
const response = await fetch('/api/dns/tailnet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDomain: this.tailnetName })
});
const result = await response.json();
if (result.success) {
this.config.baseDomain = this.tailnetName;
this.showToast('Tailnet name updated successfully!', 'success');
} else {
this.showToast('Failed to update tailnet name', 'error');
}
} catch (error) {
this.showToast('Error updating tailnet name', 'error');
} finally {
this.updating = false;
}
},
async toggleMagicDns() {
if (this.isDisabled) return;
const newValue = !this.config.magicDns;
try {
const response = await fetch('/api/dns/magic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: newValue })
});
const result = await response.json();
if (result.success) {
this.config.magicDns = newValue;
this.showToast(`Magic DNS ${newValue ? 'enabled' : 'disabled'}`, 'success');
} else {
this.showToast('Failed to toggle Magic DNS', 'error');
}
} catch (error) {
this.showToast('Error toggling Magic DNS', 'error');
}
},
updateNameserver(index, value) {
this.config.nameservers[index] = value;
},
addNameserver() {
this.config.nameservers.push('');
},
removeNameserver(index) {
this.config.nameservers.splice(index, 1);
},
addSplitDns() {
if (!this.newSplitDomain || !this.newSplitServers) return;
const servers = this.newSplitServers.split(',').map(s => s.trim()).filter(s => s);
this.config.splitDns[this.newSplitDomain] = servers;
this.showAddSplitDns = false;
this.newSplitDomain = '';
this.newSplitServers = '';
this.showToast('Split DNS configuration added', 'success');
},
removeSplitDns(domain) {
delete this.config.splitDns[domain];
this.showToast('Split DNS configuration removed', 'success');
},
resetNewRecord() {
this.newRecord = { type: 'A', name: '', value: '' };
},
addRecord() {
if (!this.newRecord.name || !this.newRecord.value) return;
this.config.extraRecords.push({ ...this.newRecord });
this.showAddRecord = false;
this.resetNewRecord();
this.showToast('DNS record added', 'success');
},
removeRecord(record) {
const index = this.config.extraRecords.findIndex(r =>
r.name === record.name && r.value === record.value
);
if (index > -1) {
this.config.extraRecords.splice(index, 1);
this.showToast('DNS record removed', 'success');
}
},
addSearchDomain() {
if (!this.newSearchDomain || this.config.searchDomains.includes(this.newSearchDomain)) return;
this.config.searchDomains.push(this.newSearchDomain);
this.newSearchDomain = '';
this.showToast('Search domain added', 'success');
},
removeSearchDomain(index) {
this.config.searchDomains.splice(index, 1);
this.showToast('Search domain removed', 'success');
},
startDrag(index) {
// Simple drag implementation placeholder
console.log('Drag started for search domain:', index);
},
showToast(message, type = 'info') {
// Use the global toast system from Layout
if (window.headyApp) {
window.headyApp().showToast(message, type);
} else {
console.log(`${type.toUpperCase()}: ${message}`);
}
}
};
}
// Make globally available
window.dnsPage = dnsPage;
</script>
</Layout>
<Layout title="DNS" hideChrome={true}>
<DnsPage client:load initial={initial} canEdit={true} />
</Layout>

View File

@ -1,373 +1,40 @@
---
import { getCollection } from 'astro:content';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
// Heady Dashboard - Alpine.js/Astro Homepage 🤠
import Layout from '../layouts/Layout.astro';
// 🤠 Heady Dashboard — Astro shell, React island.
// The whole page (nav, auth gate, content) lives in one React tree so we
// get shadcn-ui styling end-to-end. The Astro page just resolves server
// data from content collections and hands it down as props.
import { getCollection } from 'astro:content';
import { DashboardPage } from '@/components/dashboard/Dashboard';
import Layout from '@/layouts/Layout.astro';
// Fetch live data using Astro content collections
const allMachines = await getCollection('machines');
const activeSessions = await getCollection(
const allSessions = await getCollection(
'sessions',
({ data }) => data.status === 'active',
);
const recentActivity = await getCollection('activity').then((items) =>
items
.slice(0, 10)
.sort(
(a, b) =>
new Date(b.data.timestamp).getTime() -
new Date(a.data.timestamp).getTime(),
),
);
// Calculate dashboard stats
const onlineMachines = allMachines.filter((m) => m.data.online).length;
const totalMachines = allMachines.length;
const activeSessionCount = activeSessions.length;
const machines = allMachines.map((m) => ({
id: (m.data as { id?: string }).id ?? m.id,
name: m.data.name,
ip_address: m.data.ip_address,
os: m.data.os,
online: m.data.online,
}));
// System health indicators
const systemHealth = {
headscale: true, // Would be determined by actual health checks
database: true,
oidc: true,
remoteAccess: true,
};
const activeSessions = allSessions.map((s) => ({
id: (s.data as { id?: string }).id ?? s.id,
node_name: s.data.node_name,
user_email: s.data.user_email,
protocol: s.data.protocol,
status: s.data.status,
}));
---
<Layout title="Dashboard">
<AuthenticatedLayout title="Dashboard" requiredRole="member">
<div class="min-h-screen bg-gray-900" x-data="dashboard()" x-init="init()">
<!-- Header -->
<div class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white flex items-center">
<span class="mr-3">🤠</span>
Welcome to Heady
</h1>
<p class="mt-1 text-sm text-gray-400">
Strategic VPN management that's actually awesome to use
</p>
</div>
<!-- Quick Actions -->
<div class="flex space-x-3">
<a
href="/terminal"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
💻 Quick Terminal
</a>
<a
href="/machines"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
🖥️ Manage Machines
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- System Status Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Online Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">🖥️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Online Machines</dt>
<dd class="flex items-baseline">
<div class="text-2xl font-semibold text-white">{onlineMachines}</div>
<div class="ml-2 text-sm text-gray-400">/ {totalMachines}</div>
</dd>
</dl>
</div>
</div>
</div>
<!-- Active Sessions -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">⚡</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Active Sessions</dt>
<dd class="text-2xl font-semibold text-white">{activeSessionCount}</dd>
</dl>
</div>
</div>
</div>
<!-- System Health -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class={`w-8 h-8 rounded-lg flex items-center justify-center ${
Object.values(systemHealth).every(h => h) ? 'bg-green-500' : 'bg-yellow-500'
}`}>
<span class="text-white text-sm font-medium">🛡️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">System Health</dt>
<dd class="text-2xl font-semibold text-white">
{Object.values(systemHealth).every(h => h) ? 'Excellent' : 'Warning'}
</dd>
</dl>
</div>
</div>
</div>
<!-- Network Status -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-purple-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">🌐</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Network</dt>
<dd class="text-2xl font-semibold text-white">Healthy</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Recent Machines -->
<div class="lg:col-span-2">
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🖥️</span>
Machines Overview
</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{allMachines.slice(0, 5).map(machine => (
<div class="flex items-center justify-between p-4 bg-gray-900 rounded-lg">
<div class="flex items-center space-x-3">
<div class={`w-3 h-3 rounded-full ${machine.data.online ? 'bg-green-500' : 'bg-gray-500'}`}></div>
<div>
<div class="text-sm font-medium text-white">{machine.data.name}</div>
<div class="text-xs text-gray-400">{machine.data.ip_address}</div>
</div>
</div>
<div class="flex items-center space-x-2">
{machine.data.os && (
<span class="text-xs text-gray-400 capitalize">
{machine.data.os === 'linux' ? '🐧' :
machine.data.os === 'windows' ? '🪟' :
machine.data.os === 'macos' ? '🍎' : '❓'}
{machine.data.os}
</span>
)}
<span class={`text-xs px-2 py-1 rounded ${
machine.data.online ? 'bg-green-900 text-green-200' : 'bg-gray-700 text-gray-400'
}`}>
{machine.data.online ? 'Online' : 'Offline'}
</span>
</div>
</div>
))}
</div>
{allMachines.length > 5 && (
<div class="mt-4 text-center">
<a
href="/machines"
class="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
View all {totalMachines} machines →
</a>
</div>
)}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Active Sessions -->
{activeSessions.length > 0 && (
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">⚡</span>
Active Sessions
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
{activeSessions.slice(0, 3).map(session => (
<div class="flex items-center justify-between p-3 bg-gray-900 rounded">
<div class="flex items-center space-x-2">
<span class={`heady-protocol-badge protocol-${session.data.protocol}`}>
{session.data.protocol.toUpperCase()}
</span>
<span class="text-sm text-white font-mono">{session.data.node_name}</span>
</div>
<div class="text-xs text-gray-400">
{session.data.user_email.split('@')[0]}
</div>
</div>
))}
</div>
{activeSessions.length > 3 && (
<div class="mt-4 text-center">
<a
href="/terminal"
class="text-blue-400 hover:text-blue-300 text-sm font-medium"
>
View all sessions →
</a>
</div>
)}
</div>
</div>
)}
<!-- System Health Details -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🛡️</span>
System Health
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Headscale</span>
<span class={`text-sm ${systemHealth.headscale ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.headscale ? '✅ Healthy' : '❌ Unhealthy'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Database</span>
<span class={`text-sm ${systemHealth.database ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.database ? '✅ Connected' : '❌ Disconnected'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">OIDC Auth</span>
<span class={`text-sm ${systemHealth.oidc ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.oidc ? '✅ Active' : '❌ Error'}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">Remote Access</span>
<span class={`text-sm ${systemHealth.remoteAccess ? 'text-green-400' : 'text-red-400'}`}>
{systemHealth.remoteAccess ? '✅ Ready' : '❌ Unavailable'}
</span>
</div>
</div>
</div>
</div>
<!-- Quick Links -->
<div class="bg-gray-800 rounded-lg border border-gray-700">
<div class="px-6 py-4 border-b border-gray-700">
<h3 class="text-lg font-medium text-white flex items-center">
<span class="mr-2">🚀</span>
Quick Actions
</h3>
</div>
<div class="p-6">
<div class="space-y-3">
<a
href="/machines/add"
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
Add Machine
</a>
<a
href="/acls"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
🛡️ Configure ACLs
</a>
<a
href="/dns"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
🌐 Manage DNS
</a>
<a
href="/users"
class="w-full bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center"
>
👥 User Management
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function dashboard() {
return {
refreshInterval: null,
init() {
// Set up auto-refresh
this.refreshInterval = setInterval(() => {
this.refreshDashboard();
}, 30000); // Refresh every 30 seconds
// Listen for data refresh events
this.$el.addEventListener('data-refresh', () => {
this.refreshDashboard();
});
},
async refreshDashboard() {
try {
// Refresh dashboard data
const response = await fetch('/api/dashboard/stats');
if (response.ok) {
const data = await response.json();
// Update dashboard stats in real-time
// This would update the displayed numbers
console.log('Dashboard refreshed:', data);
}
} catch (error) {
console.error('Failed to refresh dashboard:', error);
}
},
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
};
}
// Make globally available
window.dashboard = dashboard;
</script>
</AuthenticatedLayout>
</Layout>
<Layout title="Dashboard" hideChrome={true}>
<DashboardPage
client:load
machines={machines}
activeSessions={activeSessions}
/>
</Layout>

View File

@ -147,7 +147,7 @@ const displayMessage =
</div>
</div>
<script>
<script is:inline>
function loginPage() {
return {
loading: false,

View File

@ -1,686 +1,25 @@
---
// 🤠 Heady Machines — Astro shell, React island.
import { getCollection } from 'astro:content';
// Heady Machines Management - Alpine.js/Astro Implementation 🤠
import Layout from '../layouts/Layout.astro';
import Layout from '@/layouts/Layout.astro';
import { MachinesPage } from '@/components/machines/MachinesPage';
// Fetch live machine data
const allMachines = await getCollection('machines');
const currentUser = await getCollection('users').then((users) => users[0]);
// Machine statistics
const totalMachines = allMachines.length;
const onlineMachines = allMachines.filter((m) => m.data.online).length;
const offlineMachines = totalMachines - onlineMachines;
const expiredMachines = allMachines.filter((m) => m.data.expired).length;
// Group machines by OS
const machinesByOS = allMachines.reduce(
(acc, machine) => {
const os = machine.data.os || 'unknown';
acc[os] = (acc[os] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
// Recent activity (would come from activity collection)
const recentActivity = [
{
action: 'Machine connected',
machine: 'dev-laptop',
timestamp: '2 minutes ago',
},
{
action: 'Machine disconnected',
machine: 'server-01',
timestamp: '1 hour ago',
},
{
action: 'Machine added',
machine: 'mobile-phone',
timestamp: '3 hours ago',
},
];
const machines = allMachines.map((m) => ({
id: m.data.id,
name: m.data.name,
hostname: m.data.hostname,
ip_address: m.data.ip_address,
online: m.data.online,
expired: m.data.expired,
os: m.data.os ?? 'unknown',
user: m.data.user,
tags: m.data.tags,
last_seen: m.data.last_seen,
}));
---
<Layout title="Machine Management">
<div
class="min-h-screen bg-gray-900"
x-data="machinesPage()"
x-init="init()"
>
<!-- Header -->
<div class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="mb-4 lg:mb-0">
<h1 class="text-2xl font-bold text-white flex items-center">
<span class="mr-3">🖥️</span>
Machine Management
</h1>
<p class="mt-1 text-sm text-gray-400">
Manage and monitor your Tailscale network devices
</p>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4">
<button
@click="refreshMachines()"
:disabled="refreshing"
class="bg-gray-600 hover:bg-gray-700 disabled:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
<svg class="h-4 w-4 mr-2" :class="refreshing ? 'animate-spin' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
@click="showAddMachine = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center"
>
Add Machine
</button>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">🖥️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Total Machines</dt>
<dd class="text-2xl font-semibold text-white">{totalMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- Online Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">✅</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Online</dt>
<dd class="text-2xl font-semibold text-white">{onlineMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- Offline Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-gray-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">⭕</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Offline</dt>
<dd class="text-2xl font-semibold text-white">{offlineMachines}</dd>
</dl>
</div>
</div>
</div>
<!-- Expired Machines -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center">
<span class="text-white text-sm font-medium">⚠️</span>
</div>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-400 truncate">Expired</dt>
<dd class="text-2xl font-semibold text-white">{expiredMachines}</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
<div class="px-6 py-4">
<div class="flex flex-col lg:flex-row gap-4 items-center justify-between">
<!-- Search -->
<div class="flex-1 max-w-md">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
x-model="searchTerm"
type="text"
placeholder="Search machines by name, IP, or user..."
class="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<!-- Filters -->
<div class="flex gap-4 items-center">
<!-- Status Filter -->
<div>
<select
x-model="statusFilter"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="all">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="expired">Expired</option>
</select>
</div>
<!-- OS Filter -->
<div>
<select
x-model="osFilter"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="all">All OS</option>
<option value="linux">🐧 Linux</option>
<option value="windows">🪟 Windows</option>
<option value="macos">🍎 macOS</option>
<option value="android">🤖 Android</option>
<option value="ios">📱 iOS</option>
<option value="unknown">❓ Unknown</option>
</select>
</div>
<!-- Sort -->
<div>
<select
x-model="sortBy"
@change="sortMachines()"
class="bg-gray-900 border border-gray-600 text-white text-sm rounded px-3 py-2"
>
<option value="name">Name</option>
<option value="status">Status</option>
<option value="last_seen">Last Seen</option>
<option value="created_at">Created</option>
<option value="os">OS</option>
</select>
</div>
<!-- View Toggle -->
<div class="flex gap-2">
<button
@click="viewMode = 'grid'"
:class="viewMode === 'grid' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
class="px-3 py-2 text-sm rounded transition-colors"
>
🔲
</button>
<button
@click="viewMode = 'table'"
:class="viewMode === 'table' ? 'bg-blue-500 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'"
class="px-3 py-2 text-sm rounded transition-colors"
>
📋
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Machines Display -->
<div class="space-y-6">
<!-- Grid View -->
<div x-show="viewMode === 'grid'" x-transition>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<template x-for="machine in filteredMachines" :key="machine.id">
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-gray-500 transition-all duration-200">
<!-- Machine Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="machine.online ? 'bg-green-500 animate-pulse' : machine.expired ? 'bg-red-500' : 'bg-gray-500'"
></div>
<h3 class="font-semibold text-white truncate" x-text="machine.name"></h3>
</div>
<!-- Machine Menu -->
<div class="relative" x-data="{ menuOpen: false }">
<button
@click="menuOpen = !menuOpen"
class="text-gray-400 hover:text-white p-1"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<div
x-show="menuOpen"
@click.away="menuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10"
>
<div class="py-1">
<button @click="editMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">✏️ Edit</button>
<button @click="renameMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">📝 Rename</button>
<button @click="expireMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">⏰ Expire</button>
<button @click="deleteMachine(machine)" class="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100">🗑️ Delete</button>
</div>
</div>
</div>
</div>
<!-- Machine Details -->
<div class="space-y-3">
<!-- IP Address -->
<div class="flex justify-between items-center">
<span class="text-sm text-gray-400">IP:</span>
<code class="text-xs text-blue-300 bg-gray-900 px-2 py-1 rounded" x-text="machine.ip_address"></code>
</div>
<!-- Operating System -->
<div class="flex justify-between items-center">
<span class="text-sm text-gray-400">OS:</span>
<div class="flex items-center space-x-1">
<span x-show="machine.os === 'linux'">🐧</span>
<span x-show="machine.os === 'windows'">🪟</span>
<span x-show="machine.os === 'macos'">🍎</span>
<span x-show="machine.os === 'android'">🤖</span>
<span x-show="machine.os === 'ios'">📱</span>
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
<span class="text-xs text-gray-300 capitalize" x-text="machine.os || 'Unknown'"></span>
</div>
</div>
<!-- User -->
<div x-show="machine.user" class="flex justify-between items-center">
<span class="text-sm text-gray-400">User:</span>
<span class="text-xs text-purple-300" x-text="machine.user"></span>
</div>
<!-- Last Seen -->
<div x-show="machine.last_seen" class="flex justify-between items-center">
<span class="text-sm text-gray-400">Last Seen:</span>
<span class="text-xs text-gray-300" x-text="formatRelativeTime(machine.last_seen)"></span>
</div>
<!-- Tags -->
<div x-show="machine.tags && machine.tags.length > 0" class="mt-3">
<div class="text-sm text-gray-400 mb-1">Tags:</div>
<div class="flex flex-wrap gap-1">
<template x-for="tag in machine.tags" :key="tag">
<span class="text-xs bg-gray-700 text-gray-300 px-2 py-1 rounded" x-text="tag"></span>
</template>
</div>
</div>
</div>
<!-- Machine Actions -->
<div class="mt-4 flex gap-2">
<button
@click="connectToMachine(machine)"
:disabled="!machine.online"
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white text-xs py-2 px-3 rounded font-medium transition-colors"
>
💻 Connect
</button>
<button
@click="showMachineDetails(machine)"
class="bg-gray-600 hover:bg-gray-700 text-white text-xs py-2 px-3 rounded font-medium transition-colors"
>
📊 Details
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Table View -->
<div x-show="viewMode === 'table'" x-transition>
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Machine</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">IP Address</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">OS</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Last Seen</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<template x-for="machine in filteredMachines" :key="machine.id">
<tr class="hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-3">
<div
class="w-3 h-3 rounded-full"
:class="machine.online ? 'bg-green-500 animate-pulse' : machine.expired ? 'bg-red-500' : 'bg-gray-500'"
></div>
<div>
<div class="text-sm font-medium text-white" x-text="machine.name"></div>
<div class="text-xs text-gray-400" x-text="machine.id?.slice(0, 8)"></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
:class="machine.online ? 'bg-green-900 text-green-200' :
machine.expired ? 'bg-red-900 text-red-200' :
'bg-gray-700 text-gray-400'"
x-text="machine.online ? 'Online' : machine.expired ? 'Expired' : 'Offline'"
></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm text-blue-300" x-text="machine.ip_address"></code>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span x-show="machine.os === 'linux'">🐧</span>
<span x-show="machine.os === 'windows'">🪟</span>
<span x-show="machine.os === 'macos'">🍎</span>
<span x-show="machine.os === 'android'">🤖</span>
<span x-show="machine.os === 'ios'">📱</span>
<span x-show="!machine.os || machine.os === 'unknown'">❓</span>
<span class="text-sm text-gray-300 capitalize" x-text="machine.os || 'Unknown'"></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-300" x-text="machine.user || '-'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-gray-300" x-text="machine.last_seen ? formatRelativeTime(machine.last_seen) : 'Never'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex space-x-2">
<button
@click="connectToMachine(machine)"
:disabled="!machine.online"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white px-3 py-1 text-xs rounded transition-colors"
>
Connect
</button>
<button
@click="editMachine(machine)"
class="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 text-xs rounded transition-colors"
>
Edit
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredMachines.length === 0" x-transition class="text-center py-12">
<span class="text-6xl mb-4 block">🤠</span>
<h3 class="text-xl font-semibold mb-2 text-white">No machines found</h3>
<p class="text-gray-400 mb-4">
<span x-show="searchTerm || statusFilter !== 'all' || osFilter !== 'all'">
No machines match your current filters.
</span>
<span x-show="!searchTerm && statusFilter === 'all' && osFilter === 'all'">
No machines have been added to your network yet.
</span>
</p>
<div class="space-x-4">
<button
@click="clearFilters()"
x-show="searchTerm || statusFilter !== 'all' || osFilter !== 'all'"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Clear Filters
</button>
<button
@click="showAddMachine = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Add First Machine
</button>
</div>
</div>
</div>
</div>
<!-- Add Machine Modal -->
<div
x-show="showAddMachine"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showAddMachine = false"
>
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
<h3 class="text-lg font-semibold text-white mb-4">Add New Machine</h3>
<div class="space-y-4">
<p class="text-sm text-gray-400">
To add a new machine to your Tailscale network, install Tailscale on the device and authenticate it.
</p>
<div class="bg-gray-900 rounded p-4">
<h4 class="text-sm font-medium text-white mb-2">Installation Command:</h4>
<code class="text-xs text-green-400 block">
curl -fsSL https://tailscale.com/install.sh | sh
</code>
</div>
<div class="bg-gray-900 rounded p-4">
<h4 class="text-sm font-medium text-white mb-2">Authentication:</h4>
<code class="text-xs text-blue-400 block">
sudo tailscale up --login-server=<span x-text="headscaleUrl"></span>
</code>
</div>
<div class="flex gap-3 pt-4">
<button
@click="showAddMachine = false"
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded font-medium transition-colors"
>
Got it
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function machinesPage() {
return {
// Machine data
machines: JSON.parse(JSON.stringify(allMachines.map(m => m.data))),
// UI state
searchTerm: '',
statusFilter: 'all',
osFilter: 'all',
sortBy: 'name',
viewMode: 'grid',
refreshing: false,
// Modals
showAddMachine: false,
// Configuration
headscaleUrl: window.location.origin,
get filteredMachines() {
let filtered = this.machines.filter(machine => {
// Search filter
const searchMatch = !this.searchTerm ||
machine.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
machine.ip_address.includes(this.searchTerm) ||
(machine.user && machine.user.toLowerCase().includes(this.searchTerm.toLowerCase()));
// Status filter
const statusMatch = this.statusFilter === 'all' ||
(this.statusFilter === 'online' && machine.online) ||
(this.statusFilter === 'offline' && !machine.online && !machine.expired) ||
(this.statusFilter === 'expired' && machine.expired);
// OS filter
const osMatch = this.osFilter === 'all' ||
(machine.os || 'unknown') === this.osFilter;
return searchMatch && statusMatch && osMatch;
});
return this.sortMachines(filtered);
},
init() {
// Parse JSON data
this.machines = JSON.parse(this.machines);
// Set up periodic refresh
setInterval(() => this.refreshMachines(), 60000); // Every minute
},
sortMachines(machines = this.machines) {
return machines.sort((a, b) => {
switch (this.sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'status':
// Online first, then offline, then expired
if (a.online !== b.online) return b.online - a.online;
if (a.expired !== b.expired) return a.expired - b.expired;
return 0;
case 'last_seen':
return new Date(b.last_seen || 0) - new Date(a.last_seen || 0);
case 'created_at':
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
case 'os':
return (a.os || 'unknown').localeCompare(b.os || 'unknown');
default:
return 0;
}
});
},
async refreshMachines() {
this.refreshing = true;
try {
const response = await fetch('/api/machines');
if (response.ok) {
this.machines = await response.json();
}
} catch (error) {
console.error('Failed to refresh machines:', error);
} finally {
this.refreshing = false;
}
},
connectToMachine(machine) {
// Navigate to terminal with this machine
window.location.href = `/terminal?machine=${machine.name}&protocol=ssh`;
},
editMachine(machine) {
// Show edit modal (would be implemented)
console.log('Edit machine:', machine);
},
renameMachine(machine) {
const newName = prompt('Enter new name for machine:', machine.name);
if (newName && newName !== machine.name) {
this.updateMachine(machine.id, { name: newName });
}
},
async expireMachine(machine) {
if (confirm(`Expire machine "${machine.name}"? This will disconnect it from the network.`)) {
try {
const response = await fetch(`/api/machines/${machine.id}/expire`, {
method: 'POST'
});
if (response.ok) {
await this.refreshMachines();
}
} catch (error) {
console.error('Failed to expire machine:', error);
}
}
},
async deleteMachine(machine) {
if (confirm(`Delete machine "${machine.name}"? This action cannot be undone.`)) {
try {
const response = await fetch(`/api/machines/${machine.id}`, {
method: 'DELETE'
});
if (response.ok) {
await this.refreshMachines();
}
} catch (error) {
console.error('Failed to delete machine:', error);
}
}
},
showMachineDetails(machine) {
// Show detailed machine information modal
console.log('Show details for:', machine);
},
clearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.osFilter = 'all';
},
formatRelativeTime(timestamp) {
const now = new Date();
const time = new Date(timestamp);
const diffMs = now.getTime() - time.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMinutes > 0) return `${diffMinutes}m ago`;
return 'Just now';
}
};
}
// Make globally available
window.machinesPage = machinesPage;
</script>
</Layout>
<Layout title="Machines" hideChrome={true}>
<MachinesPage client:load machines={machines} />
</Layout>

File diff suppressed because it is too large Load Diff

View File

@ -414,7 +414,7 @@ const availableProtocols = Object.entries(userPermissions)
</div>
</div>
<script define:vars={{ onlineMachines, activeSessions, userPermissions, currentUser }}>
<script is:inline define:vars={{ onlineMachines, activeSessions, userPermissions, currentUser }}>
function terminalPage() {
return {
// Current user and permissions

View File

@ -1,747 +1,23 @@
---
// 🤠 Heady Users — Astro shell, React island.
import { getCollection } from 'astro:content';
// Heady User Management - Alpine.js/Astro User Management 🤠
import Layout from '../layouts/Layout.astro';
import Layout from '@/layouts/Layout.astro';
import { UsersPage } from '@/components/users/UsersPage';
// Mock user data (in production, this would come from Headscale API and OIDC)
const usersData = [
{
id: 'user-1',
name: 'Alice Johnson',
displayName: 'Alice J.',
email: 'alice@company.com',
profilePicUrl: null,
provider: 'oidc',
providerId: 'https://auth.company.com/user/alice-123',
createdAt: '2024-01-15T10:30:00Z',
role: 'admin',
headplaneRole: 'admin',
machines: [
{
id: 'machine-1',
name: 'alice-laptop',
online: true,
lastSeen: new Date().toISOString(),
user: { id: 'user-1' },
},
{
id: 'machine-2',
name: 'alice-phone',
online: false,
lastSeen: '2024-01-20T14:20:00Z',
user: { id: 'user-1' },
},
],
},
{
id: 'user-2',
name: 'Bob Smith',
displayName: 'Bob S.',
email: 'bob@company.com',
profilePicUrl: null,
provider: 'oidc',
providerId: 'https://auth.company.com/user/bob-456',
createdAt: '2024-01-16T09:15:00Z',
role: 'member',
headplaneRole: 'member',
machines: [
{
id: 'machine-3',
name: 'bob-desktop',
online: true,
lastSeen: new Date().toISOString(),
user: { id: 'user-2' },
},
],
},
{
id: 'user-3',
name: 'Carol Wilson',
displayName: 'Carol W.',
email: 'carol@company.com',
profilePicUrl: null,
provider: 'oidc',
providerId: 'https://auth.company.com/user/carol-789',
createdAt: '2024-01-18T16:45:00Z',
role: 'network_admin',
headplaneRole: 'network_admin',
machines: [
{
id: 'machine-4',
name: 'carol-laptop',
online: false,
lastSeen: '2024-01-21T08:30:00Z',
user: { id: 'user-3' },
},
{
id: 'machine-5',
name: 'carol-tablet',
online: true,
lastSeen: new Date().toISOString(),
user: { id: 'user-3' },
},
],
},
{
id: 'user-4',
name: 'dave-server',
displayName: 'Dave Server',
email: '',
profilePicUrl: null,
provider: 'cli',
providerId: null,
createdAt: '2024-01-10T12:00:00Z',
role: 'unmanaged',
headplaneRole: 'no-oidc',
machines: [
{
id: 'machine-6',
name: 'production-server',
online: true,
lastSeen: new Date().toISOString(),
user: { id: 'user-4' },
},
],
},
];
// Mock permissions and configuration
const userConfig = {
writable: true, // User can manage other users
oidc: {
enabled: true,
issuer: 'https://auth.company.com',
clientId: 'heady-app',
},
magic: 'heady.local', // Magic DNS domain
};
// Get recent user-related activity
const recentActivity = await getCollection(
'activity',
({ data }) => data.resource_type === 'user',
).then((items) =>
items
.slice(0, 5)
.sort(
(a, b) =>
new Date(b.data.timestamp).getTime() -
new Date(a.data.timestamp).getTime(),
),
);
const allUsers = await getCollection('users');
const users = allUsers.map((u) => ({
id: u.data.id,
email: u.data.email,
name: u.data.name,
preferred_username: u.data.preferred_username,
role: u.data.role,
groups: u.data.groups,
picture: (u.data as { picture?: string }).picture,
last_login: u.data.last_login,
}));
---
<Layout title="User Management">
<div class="min-h-screen bg-gray-900" x-data="usersPage()" x-init="init()">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-4">
👥 Users
</h1>
<p class="text-gray-300 max-w-4xl">
Manage the users in your network and their permissions. Control access levels and monitor user activity.
</p>
</div>
<!-- OIDC Configuration Banner -->
<template x-if="config.oidc.enabled">
<div class="mb-6 p-4 bg-blue-900/50 border border-blue-700 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="text-blue-500 text-xl mr-3">🔐</span>
<div>
<h3 class="text-blue-200 font-semibold">OIDC Authentication Enabled</h3>
<p class="text-blue-300 text-sm mt-1">
Users authenticate via <code class="bg-blue-800 px-1 rounded" x-text="config.oidc.issuer"></code>
</p>
</div>
</div>
<template x-if="config.writable">
<button
@click="showCreateUser = true"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
👤 Invite User
</button>
</template>
</div>
</div>
</template>
<!-- Users Grid/Table Toggle -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- Search -->
<div class="relative">
<input
x-model="searchQuery"
@input="filterUsers()"
type="text"
placeholder="Search users..."
class="bg-gray-800 border border-gray-600 rounded-lg pl-10 pr-4 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span class="absolute left-3 top-2.5 text-gray-400">🔍</span>
</div>
<!-- Role Filter -->
<select
x-model="roleFilter"
@change="filterUsers()"
class="bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Roles</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="network_admin">Network Admin</option>
<option value="it_admin">IT Admin</option>
<option value="auditor">Auditor</option>
<option value="member">Member</option>
<option value="no-oidc">Unmanaged</option>
<option value="no-role">Unregistered</option>
</select>
<!-- Status Filter -->
<select
x-model="statusFilter"
@change="filterUsers()"
class="bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Status</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
</select>
</div>
<!-- View Toggle -->
<div class="flex items-center space-x-2">
<button
@click="viewMode = 'table'"
:class="viewMode === 'table' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
>
📋 Table
</button>
<button
@click="viewMode = 'cards'"
:class="viewMode === 'cards' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'"
class="px-3 py-2 rounded-lg text-sm font-medium transition-colors"
>
🎴 Cards
</button>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center">
<span class="text-2xl mr-3">👥</span>
<div>
<div class="text-xl font-bold text-white" x-text="users.length"></div>
<div class="text-sm text-gray-400">Total Users</div>
</div>
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center">
<span class="text-2xl mr-3">🟢</span>
<div>
<div class="text-xl font-bold text-white" x-text="onlineUsers"></div>
<div class="text-sm text-gray-400">Online Now</div>
</div>
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center">
<span class="text-2xl mr-3">🔐</span>
<div>
<div class="text-xl font-bold text-white" x-text="oidcUsers"></div>
<div class="text-sm text-gray-400">OIDC Users</div>
</div>
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div class="flex items-center">
<span class="text-2xl mr-3">⚡</span>
<div>
<div class="text-xl font-bold text-white" x-text="totalMachines"></div>
<div class="text-sm text-gray-400">Total Machines</div>
</div>
</div>
</div>
</div>
<!-- Users Display -->
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<!-- Table View -->
<div x-show="viewMode === 'table'">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700 border-b border-gray-600">
<tr>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">User</th>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">Role</th>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">Machines</th>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">Created</th>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">Last Seen</th>
<th class="text-left px-6 py-4 text-xs font-bold text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<template x-for="user in filteredUsers" :key="user.id">
<tr class="hover:bg-gray-700/50">
<!-- User Info -->
<td class="px-6 py-4">
<div class="flex items-center">
<template x-if="user.profilePicUrl">
<img
:src="user.profilePicUrl"
:alt="user.name || user.displayName"
class="w-10 h-10 rounded-full"
/>
</template>
<template x-if="!user.profilePicUrl">
<div class="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
<span class="text-gray-300 text-lg">👤</span>
</div>
</template>
<div class="ml-4">
<div class="text-sm font-semibold text-white" x-text="user.name || user.displayName"></div>
<div class="text-sm text-gray-400" x-text="user.email || 'No email'"></div>
</div>
</div>
</td>
<!-- Role -->
<td class="px-6 py-4">
<span
:class="getRoleClass(user.headplaneRole)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
x-text="mapRoleToName(user.headplaneRole)"
></span>
</td>
<!-- Machines -->
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<span class="text-sm text-white" x-text="user.machines.length"></span>
<template x-if="getOnlineMachines(user).length > 0">
<span class="text-xs text-green-400">
(<span x-text="getOnlineMachines(user).length"></span> online)
</span>
</template>
</div>
</td>
<!-- Created -->
<td class="px-6 py-4 text-sm text-gray-400" x-text="formatDate(user.createdAt)"></td>
<!-- Last Seen -->
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<div
:class="isUserOnline(user) ? 'bg-green-500' : 'bg-gray-500'"
class="w-2 h-2 rounded-full"
></div>
<span class="text-sm text-gray-400" x-text="getLastSeenText(user)"></span>
</div>
</td>
<!-- Actions -->
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<button
@click="editUser(user)"
:disabled="!config.writable"
class="text-blue-400 hover:text-blue-300 disabled:opacity-50 p-1"
title="Edit user"
>
✏️
</button>
<button
@click="viewUserMachines(user)"
class="text-green-400 hover:text-green-300 p-1"
title="View machines"
>
🖥️
</button>
<button
@click="deleteUser(user)"
:disabled="!config.writable || user.headplaneRole === 'owner'"
class="text-red-400 hover:text-red-300 disabled:opacity-50 p-1"
title="Delete user"
>
🗑️
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="filteredUsers.length === 0">
<div class="text-center py-12">
<span class="text-4xl mb-4 block">👥</span>
<h3 class="text-lg font-semibold text-white mb-2">No users found</h3>
<p class="text-gray-400">Try adjusting your search or filter criteria.</p>
</div>
</template>
</div>
<!-- Cards View -->
<div x-show="viewMode === 'cards'" class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="user in filteredUsers" :key="user.id">
<div class="bg-gray-900 rounded-lg border border-gray-600 p-6">
<!-- User Header -->
<div class="flex items-center mb-4">
<template x-if="user.profilePicUrl">
<img
:src="user.profilePicUrl"
:alt="user.name || user.displayName"
class="w-12 h-12 rounded-full"
/>
</template>
<template x-if="!user.profilePicUrl">
<div class="w-12 h-12 bg-gray-600 rounded-full flex items-center justify-center">
<span class="text-gray-300 text-xl">👤</span>
</div>
</template>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-white" x-text="user.name || user.displayName"></h3>
<p class="text-sm text-gray-400" x-text="user.email || 'No email'"></p>
</div>
<div
:class="isUserOnline(user) ? 'bg-green-500' : 'bg-gray-500'"
class="w-3 h-3 rounded-full"
></div>
</div>
<!-- Role Badge -->
<div class="mb-4">
<span
:class="getRoleClass(user.headplaneRole)"
class="inline-flex px-3 py-1 text-sm font-semibold rounded-full"
x-text="mapRoleToName(user.headplaneRole)"
></span>
</div>
<!-- Stats -->
<div class="space-y-2 mb-4 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Machines:</span>
<span class="text-white" x-text="user.machines.length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Online:</span>
<span class="text-green-400" x-text="getOnlineMachines(user).length"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Created:</span>
<span class="text-white" x-text="formatDate(user.createdAt)"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Last Seen:</span>
<span class="text-white" x-text="getLastSeenText(user)"></span>
</div>
</div>
<!-- Actions -->
<div class="flex space-x-2">
<button
@click="editUser(user)"
:disabled="!config.writable"
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:opacity-50 text-white px-3 py-2 rounded text-sm font-medium transition-colors"
>
✏️ Edit
</button>
<button
@click="viewUserMachines(user)"
class="flex-1 bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded text-sm font-medium transition-colors"
>
🖥️ Machines
</button>
</div>
</div>
</template>
</div>
<template x-if="filteredUsers.length === 0">
<div class="text-center py-12">
<span class="text-4xl mb-4 block">👥</span>
<h3 class="text-lg font-semibold text-white mb-2">No users found</h3>
<p class="text-gray-400">Try adjusting your search or filter criteria.</p>
</div>
</template>
</div>
</div>
<!-- Create User Modal -->
<div
x-show="showCreateUser"
x-transition
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
@click.self="showCreateUser = false"
>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6 w-full max-w-md">
<h3 class="text-xl font-semibold text-white mb-4">👤 Invite User</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Email Address</label>
<input
x-model="newUser.email"
type="email"
placeholder="user@company.com"
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Role</label>
<select
x-model="newUser.role"
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
<option value="network_admin">Network Admin</option>
<option value="it_admin">IT Admin</option>
<option value="auditor">Auditor</option>
</select>
</div>
</div>
<div class="flex space-x-3 mt-6">
<button
@click="createUser()"
:disabled="!newUser.email"
class="flex-1 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:opacity-50 text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
Send Invitation
</button>
<button
@click="showCreateUser = false"
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function usersPage() {
return {
// Initialize with server data
users: JSON.parse(JSON.stringify(usersData)),
config: JSON.parse(JSON.stringify(userConfig)),
// UI state
viewMode: 'table',
searchQuery: '',
roleFilter: '',
statusFilter: '',
filteredUsers: [],
showCreateUser: false,
newUser: { email: '', role: 'member' },
// Computed properties
get onlineUsers() {
return this.users.filter(user => this.isUserOnline(user)).length;
},
get oidcUsers() {
return this.users.filter(user => user.provider === 'oidc').length;
},
get totalMachines() {
return this.users.reduce((total, user) => total + user.machines.length, 0);
},
init() {
this.filteredUsers = [...this.users];
console.log('Users page initialized with', this.users.length, 'users');
},
filterUsers() {
let filtered = this.users;
// Search filter
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
filtered = filtered.filter(user =>
(user.name || user.displayName || '').toLowerCase().includes(query) ||
(user.email || '').toLowerCase().includes(query)
);
}
// Role filter
if (this.roleFilter) {
filtered = filtered.filter(user => user.headplaneRole === this.roleFilter);
}
// Status filter
if (this.statusFilter) {
if (this.statusFilter === 'online') {
filtered = filtered.filter(user => this.isUserOnline(user));
} else if (this.statusFilter === 'offline') {
filtered = filtered.filter(user => !this.isUserOnline(user));
}
}
this.filteredUsers = filtered.sort((a, b) =>
(a.name || a.displayName || '').localeCompare(b.name || b.displayName || '')
);
},
isUserOnline(user) {
return user.machines.some(machine => machine.online);
},
getOnlineMachines(user) {
return user.machines.filter(machine => machine.online);
},
getLastSeenText(user) {
if (this.isUserOnline(user)) {
return 'Connected';
}
const lastSeen = user.machines.reduce(
(latest, machine) => {
const machineTime = new Date(machine.lastSeen).getTime();
return machineTime > latest ? machineTime : latest;
},
0
);
if (lastSeen === 0) {
return 'Never';
}
return new Date(lastSeen).toLocaleString();
},
mapRoleToName(role) {
const roleMap = {
'no-oidc': 'Unmanaged',
'invalid-oidc': 'Invalid',
'no-role': 'Unregistered',
'owner': 'Owner',
'admin': 'Admin',
'network_admin': 'Network Admin',
'it_admin': 'IT Admin',
'auditor': 'Auditor',
'member': 'Member'
};
return roleMap[role] || 'Unknown';
},
getRoleClass(role) {
const roleClasses = {
'owner': 'bg-purple-900 text-purple-200',
'admin': 'bg-red-900 text-red-200',
'network_admin': 'bg-orange-900 text-orange-200',
'it_admin': 'bg-yellow-900 text-yellow-200',
'auditor': 'bg-blue-900 text-blue-200',
'member': 'bg-green-900 text-green-200',
'no-oidc': 'bg-gray-700 text-gray-300',
'invalid-oidc': 'bg-gray-700 text-gray-300',
'no-role': 'bg-gray-700 text-gray-300'
};
return roleClasses[role] || 'bg-gray-700 text-gray-300';
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
},
async createUser() {
if (!this.newUser.email) return;
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newUser)
});
const result = await response.json();
if (result.success) {
// Add to local users list
this.users.push(result.user);
this.filterUsers();
this.showCreateUser = false;
this.newUser = { email: '', role: 'member' };
this.showToast('User invitation sent successfully!', 'success');
} else {
this.showToast('Failed to send invitation', 'error');
}
} catch (error) {
this.showToast('Error sending invitation', 'error');
}
},
editUser(user) {
this.showToast(`Editing user: ${user.name || user.displayName}`, 'info');
// In a real app, this would open an edit modal
},
viewUserMachines(user) {
this.showToast(`Viewing machines for: ${user.name || user.displayName}`, 'info');
// In a real app, this would navigate to machines filtered by user
},
async deleteUser(user) {
if (user.headplaneRole === 'owner') {
this.showToast('Cannot delete owner user', 'error');
return;
}
if (!confirm(`Are you sure you want to delete user "${user.name || user.displayName}"?`)) {
return;
}
try {
const response = await fetch(`/api/users/${user.id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
// Remove from local users list
this.users = this.users.filter(u => u.id !== user.id);
this.filterUsers();
this.showToast('User deleted successfully', 'success');
} else {
this.showToast('Failed to delete user', 'error');
}
} catch (error) {
this.showToast('Error deleting user', 'error');
}
},
showToast(message, type = 'info') {
// Use the global toast system from Layout
if (window.headyApp) {
window.headyApp().showToast(message, type);
} else {
console.log(`${type.toUpperCase()}: ${message}`);
}
}
};
}
// Make globally available
window.usersPage = usersPage;
</script>
</Layout>
<Layout title="Users" hideChrome={true}>
<UsersPage client:load users={users} />
</Layout>

View File

@ -1,3 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* shadcn-ui (new-york) theme tokens.
We default to dark for Heady's identity; `:root` and `.dark` carry the
same palette so anywhere we toggle theme later, the dark scheme is
consistent. Swap the .dark block back to the light defaults if you want
theme switching back. */
@layer base {
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 47.4% 11.2%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 47.4% 11.2%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}

View File

@ -1,15 +1,59 @@
/** @type {import('tailwindcss').Config} */
import tailwindcssAnimate from 'tailwindcss-animate';
export default {
darkMode: 'class',
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'./src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
'./src/pages/**/*.astro',
'./src/layouts/**/*.astro',
'./src/components/**/*.astro',
'./src/components/**/*.{astro,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
// shadcn-ui (new-york) reads colors from CSS variables defined in
// src/styles/global.css. Everything below maps Tailwind utility
// classes to those variables.
colors: {
// Heady brand colors
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
// Brand accents kept from the original Heady config.
heady: {
50: '#f0f9ff',
100: '#e0f2fe',
@ -23,21 +67,30 @@ export default {
900: '#0c4a6e',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
mono: [
'Fira Code',
'Monaco',
'Cascadia Code',
'Roboto Mono',
'monospace',
],
mono: ['Fira Code', 'Monaco', 'Cascadia Code', 'monospace'],
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
plugins: [
// Add any Tailwind plugins you need
],
plugins: [tailwindcssAnimate],
};

View File

@ -21,9 +21,11 @@
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
"~server/*": ["./server/*"]
"~server/*": ["./server/*"],
"@/*": ["./src/*"]
},
// Vite takes care of building everything, not tsc.