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:
parent
36c1b18724
commit
09b1bae157
@ -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:
|
||||
|
||||
11
package.json
11
package.json
@ -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
1029
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
406
src/components/dashboard/Dashboard.tsx
Normal file
406
src/components/dashboard/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
512
src/components/dns/DnsPage.tsx
Normal file
512
src/components/dns/DnsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
356
src/components/machines/MachinesPage.tsx
Normal file
356
src/components/machines/MachinesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
648
src/components/settings/SettingsPage.tsx
Normal file
648
src/components/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
317
src/components/shell/AppShell.tsx
Normal file
317
src/components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/ui/avatar.tsx
Normal file
47
src/components/ui/avatar.tsx
Normal 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 };
|
||||
37
src/components/ui/badge.tsx
Normal file
37
src/components/ui/badge.tsx
Normal 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 };
|
||||
53
src/components/ui/button.tsx
Normal file
53
src/components/ui/button.tsx
Normal 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 };
|
||||
82
src/components/ui/card.tsx
Normal file
82
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
143
src/components/ui/dropdown-menu.tsx
Normal file
143
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||
147
src/components/ui/select.tsx
Normal file
147
src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal 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 };
|
||||
26
src/components/ui/switch.tsx
Normal file
26
src/components/ui/switch.tsx
Normal 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 };
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal 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 };
|
||||
303
src/components/users/UsersPage.tsx
Normal file
303
src/components/users/UsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
10
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@ -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}`);
|
||||
|
||||
@ -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' } },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -147,7 +147,7 @@ const displayMessage =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script is:inline>
|
||||
function loginPage() {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
@ -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
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user