diff --git a/.changeset/admin-dashboard.md b/.changeset/admin-dashboard.md new file mode 100644 index 00000000..f56275f1 --- /dev/null +++ b/.changeset/admin-dashboard.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": minor +--- + +Add the self-hosted Caplets Admin Dashboard with Operator Client sessions, role-gated admin routes, dashboard access/catalog/Vault/runtime APIs, redacted operator activity logging, and a static dashboard UI shell. diff --git a/.gitignore b/.gitignore index 9da68f70..89f1c801 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ benchmark-results/ # Pi .pi-lens/ !.pi-lens/semgrep.json +.pi-subagents/ # Playwright .playwright-mcp/ diff --git a/CONCEPTS.md b/CONCEPTS.md index deca1f40..9f2fd85c 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -72,6 +72,18 @@ A per-user native service managed by `caplets daemon` that runs local HTTP `capl The Caplets Daemon is installed and updated through an install-time service contract. Runtime lifecycle commands operate on the installed service rather than changing its persisted serve or environment configuration. +### Current Host + +The Caplets host that served the active dashboard session and owns the runtime state being administered in that session. + +Current Host is a session-scoped administration target, not a product-wide singleton. The admin dashboard may initially operate only on the Current Host while preserving host-scoped terms for future multi-host enumeration and switching. + +### Caplets Admin Dashboard + +A browser UI served by the Caplets host for managing the Current Host's Caplets, remote clients, Vault, Project Binding state, daemon health, logs, and catalog-backed installs. + +The Caplets Admin Dashboard is an operator surface over structured Caplets administration. It is not a raw config editor, a separate service, or a generic multi-host control plane in its first release. + ### Daemon-First Setup The local onboarding path where `caplets setup` prepares the user config and a healthy Caplets Daemon before configuring agent integrations. @@ -225,3 +237,27 @@ Pairing Codes prove that a server-local operator approved a specific pending log The stored local record for a trusted Caplets host, including the normalized host URL, host kind, selected workspace when applicable, and redacted credential status. Remote Profiles are the source of truth for request credentials. Long-lived clients resolve current profile state when sending remote traffic instead of copying credentials into agent config or one-time startup state. + +### Remote Client Role + +The server-side authorization role assigned to a paired remote client after a Pending Remote Login is approved. + +Remote Client Roles split ordinary runtime access from host administration. They are scoped to the Caplets host that issued the credentials so a future multi-host dashboard can preserve independent authority per host. + +### Access Client + +A paired remote client whose role allows Remote Attach, MCP, and Project Binding access without host administration. + +Access Clients are for agent-facing and attach-facing runtime use. They do not authorize dashboard operations, remote-client administration, catalog install or update, Vault administration, or generic admin control. + +### Operator Client + +A paired remote client whose role allows dashboard and host administration operations against a Caplets host. + +Operator Clients can approve or revoke remote clients, administer Caplets and catalog installs, manage Vault state, view diagnostics, and perform dedicated human-only reveal actions where allowed. Operator authority is granted through the same Pending Remote Login approval lane as ordinary access, but with an operator role request. + +### Operator Activity Log + +A host-owned record of sensitive Operator Client actions performed through the dashboard or operator admin surfaces. + +Operator Activity Log answers what changed, when it changed, and which Operator Client performed the action. It is narrower than a compliance audit system and separate from daemon or protocol logs. diff --git a/CONTEXT.md b/CONTEXT.md index 7ddb4760..3fd8cf84 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,3 +39,23 @@ _Avoid_: Inline blob, base64 result, download blob **Caplets Vault**: A runtime-owned encrypted string store whose values can be referenced from Caplets config with `$vault:NAME` or `${vault:NAME}`. _Avoid_: Caplets Secrets, project secrets, shared encrypted project vault + +**Remote Client Role**: +The server-side authorization role assigned to a paired remote client after a Pending Remote Login is approved. +_Avoid_: Device token type, browser permission flag + +**Access Client**: +A paired remote client whose role allows Remote Attach, MCP, and Project Binding access without host administration. +_Avoid_: Regular device token, user token + +**Operator Client**: +A paired remote client whose role allows dashboard and admin operations against the Caplets host, including remote-client administration, Caplet installation and configuration, and Vault administration. +_Avoid_: Admin device token, dashboard token + +**Current Host**: +The Caplets host that served the active dashboard session and owns the runtime state being administered in that session. +_Avoid_: Only server, global host singleton + +**Operator Activity Log**: +A host-owned record of sensitive Operator Client actions performed through the dashboard or operator admin surfaces. +_Avoid_: Daemon logs, compliance audit system diff --git a/apps/dashboard/astro.config.mjs b/apps/dashboard/astro.config.mjs new file mode 100644 index 00000000..57e65a2d --- /dev/null +++ b/apps/dashboard/astro.config.mjs @@ -0,0 +1,27 @@ +import react from "@astrojs/react"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "astro/config"; + +const dashboardApiTarget = process.env.CAPLETS_DASHBOARD_API_TARGET; + +export default defineConfig({ + output: "static", + integrations: [react()], + vite: { + plugins: [tailwindcss()], + ...(dashboardApiTarget + ? { + server: { + proxy: { + "/dashboard/api": { + target: dashboardApiTarget, + changeOrigin: false, + secure: false, + ws: true, + }, + }, + }, + } + : {}), + }, +}); diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 00000000..42acebc2 --- /dev/null +++ b/apps/dashboard/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 00000000..f555071e --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,35 @@ +{ + "name": "@caplets/dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "astro build", + "dev": "astro dev" + }, + "dependencies": { + "@astrojs/check": "^0.9.9", + "@astrojs/react": "^6.0.1", + "@base-ui/react": "^1.6.0", + "@tailwindcss/vite": "^4.3.1", + "astro": "^7.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.23.0", + "next-themes": "^0.4.6", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "shadcn": "^4.12.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.1", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "vite": "^8.1.0" + } +} diff --git a/apps/dashboard/public/dashboard/favicon.png b/apps/dashboard/public/dashboard/favicon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/dashboard/public/dashboard/favicon.png differ diff --git a/apps/dashboard/public/dashboard/icon-header-dark.png b/apps/dashboard/public/dashboard/icon-header-dark.png new file mode 100644 index 00000000..7f54aa66 Binary files /dev/null and b/apps/dashboard/public/dashboard/icon-header-dark.png differ diff --git a/apps/dashboard/public/dashboard/icon.png b/apps/dashboard/public/dashboard/icon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/dashboard/public/dashboard/icon.png differ diff --git a/apps/dashboard/public/favicon.png b/apps/dashboard/public/favicon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/dashboard/public/favicon.png differ diff --git a/apps/dashboard/public/icon-header-dark.png b/apps/dashboard/public/icon-header-dark.png new file mode 100644 index 00000000..7f54aa66 Binary files /dev/null and b/apps/dashboard/public/icon-header-dark.png differ diff --git a/apps/dashboard/public/icon.png b/apps/dashboard/public/icon.png new file mode 100644 index 00000000..4eebda4b Binary files /dev/null and b/apps/dashboard/public/icon.png differ diff --git a/apps/dashboard/src/components/DashboardApp.tsx b/apps/dashboard/src/components/DashboardApp.tsx new file mode 100644 index 00000000..a048248b --- /dev/null +++ b/apps/dashboard/src/components/DashboardApp.tsx @@ -0,0 +1,3081 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import { + ActivityIcon, + AlertTriangleIcon, + BoxIcon, + BoxesIcon, + CheckIcon, + ClipboardListIcon, + ComputerIcon, + DatabaseIcon, + ExternalLinkIcon, + EyeIcon, + EyeOffIcon, + HomeIcon, + KeyIcon, + KeyRoundIcon, + LinkIcon, + LogOutIcon, + MenuIcon, + MoonIcon, + RefreshCwIcon, + SearchIcon, + SettingsIcon, + ShieldCheckIcon, + SunIcon, + TerminalIcon, + Trash2Icon, +} from "lucide-react"; +import { ThemeProvider, useTheme } from "next-themes"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@/components/ui/sidebar"; +import { Toaster } from "@/components/ui/sonner"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + dashboardApi, + isDashboardUnauthorized, + setDashboardSession, + type DashboardSession, +} from "@/lib/api"; +import { dashboardBasePath, dashboardPath } from "@/lib/paths"; + +type RouteKey = + | "overview" + | "access" + | "caplets" + | "catalog" + | "vault" + | "runtime" + | "activity" + | "settings"; + +type Summary = { + host?: { baseUrl?: string; version?: string }; + attention?: Array<{ label: string; severity?: string; kind?: string }>; + sections?: Record; +}; + +type CapletRecord = Record & { + id?: string; + name?: string; + title?: string; + description?: string; + kind?: string; + source?: string; + updateState?: string; + authRequired?: boolean | "true" | "false"; + setupRequired?: boolean | "true" | "false"; + projectBindingRequired?: boolean | "true" | "false"; +}; + +type DashboardData = { + summary?: Summary; + caplets?: { caplets?: Array; error?: string }; + clients?: { clients?: Array>; error?: string }; + pending?: { pendingLogins?: Array>; error?: string }; + vault?: { + values?: Array>; + grants?: Array>; + error?: string; + }; + runtime?: { runtime?: Record; daemon?: Record; error?: string }; + diagnostics?: { status?: string; checks?: Array>; error?: string }; + activity?: { entries?: Array>; error?: string }; + logs?: { entries?: Array>; error?: string }; + projectBinding?: { + projectBinding?: { state?: string; actions?: Array> }; + error?: string; + }; + updates?: { + ready?: boolean; + reason?: string; + updates?: Array<{ id?: string; status?: string; risk?: unknown }>; + error?: string; + }; +}; + +const routes: Array<{ + key: RouteKey; + label: string; + href: string; + icon: typeof HomeIcon; +}> = [ + { key: "overview", label: "Overview", href: dashboardPath(), icon: HomeIcon }, + { key: "access", label: "Access", href: dashboardPath("access"), icon: ShieldCheckIcon }, + { key: "caplets", label: "Caplets", href: dashboardPath("caplets"), icon: BoxesIcon }, + { key: "catalog", label: "Catalog", href: dashboardPath("catalog"), icon: BoxIcon }, + { key: "vault", label: "Vault", href: dashboardPath("vault"), icon: KeyRoundIcon }, + { key: "runtime", label: "Runtime", href: dashboardPath("runtime"), icon: TerminalIcon }, + { key: "activity", label: "Activity", href: dashboardPath("activity"), icon: ActivityIcon }, + { key: "settings", label: "Settings", href: dashboardPath("settings"), icon: ClipboardListIcon }, +]; + +function routeHref(route: RouteKey): string { + return routes.find((item) => item.key === route)?.href ?? dashboardPath(); +} + +function routeLabel(route: RouteKey): string { + return routes.find((item) => item.key === route)?.label ?? "Overview"; +} + +const dashboardThemeColors: Record<"light" | "dark", string> = { + light: "#f6f0df", + dark: "#1d1b16", +}; + +type DashboardTheme = "light" | "dark" | "system"; + +function dashboardBoolean(value: unknown): boolean { + return value === true || value === "true"; +} + +type ConfirmationOptions = { + title: string; + description: string; + confirmLabel?: string; + destructive?: boolean; + expectedPhrase?: string; +}; + +type ConfirmationResult = boolean | string; + +type ConfirmationRequest = ConfirmationOptions & { + resolve: (confirmed: ConfirmationResult) => void; + finalFocus?: HTMLElement | null; +}; + +const ConfirmContext = createContext< + ((options: ConfirmationOptions) => Promise) | null +>(null); + +function useConfirm() { + const confirm = useContext(ConfirmContext); + if (!confirm) throw new Error("useConfirm must be used inside DashboardApp."); + return confirm; +} + +function useActionConfirm() { + const confirm = useConfirm(); + return { + confirmAction: async (title: string, description: string) => + Boolean(await confirm({ title, description, confirmLabel: "Continue" })), + confirmTyped: async (title: string, description: string, expectedPhrase: string) => + Boolean( + await confirm({ + title, + description, + expectedPhrase, + confirmLabel: "Confirm", + destructive: true, + }), + ), + }; +} + +export function DashboardApp({ initialRoute = "overview" }: { initialRoute?: RouteKey }) { + const [route, setRoute] = useState(initialRoute); + const [session, setSession] = useState(); + const [data, setData] = useState({}); + const [loading, setLoading] = useState(true); + const [dataLoading, setDataLoading] = useState(true); + const [authCommand, setAuthCommand] = useState(""); + const [authMessage, setAuthMessage] = useState("Restoring dashboard session…"); + const [confirmation, setConfirmation] = useState(); + + const confirm = useCallback((options: ConfirmationOptions) => { + const finalFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + return new Promise((resolve) => + setConfirmation({ ...options, finalFocus, resolve }), + ); + }, []); + + const resolveConfirmation = useCallback( + (confirmed: ConfirmationResult) => { + confirmation?.resolve(confirmed); + setConfirmation(undefined); + }, + [confirmation], + ); + + useEffect(() => { + let cancelled = false; + void restoreDashboardSession(0, () => cancelled); + return () => { + cancelled = true; + }; + }, []); + + function endDashboardSession(message = "Authorization required.") { + setSession(undefined); + setDashboardSession(undefined); + setData({}); + setAuthCommand(""); + setAuthMessage(message); + setLoading(false); + setDataLoading(false); + } + + async function restoreDashboardSession(attempt: number, cancelled: () => boolean) { + setLoading(true); + try { + const result = await dashboardApi<{ authenticated: boolean; session: DashboardSession }>( + "session", + ); + if (cancelled()) return; + setSession(result.session); + setDashboardSession(result.session); + const refreshed = await refresh(); + if (!cancelled() && refreshed) setLoading(false); + } catch (error) { + if (cancelled()) return; + if (isDashboardUnauthorized(error)) { + endDashboardSession(); + return; + } + setAuthMessage("Reconnecting to the Current Host…"); + window.setTimeout( + () => restoreDashboardSession(attempt + 1, cancelled), + Math.min(10_000, 1_000 + attempt * 1_000), + ); + } + } + + async function refresh(): Promise { + setDataLoading(true); + try { + const loaded = await Promise.all([ + load("summary", "summary"), + load("caplets", "caplets"), + load("clients", "access/clients"), + load("pending", "access/pending-logins"), + load("vault", "vault"), + load("runtime", "runtime"), + load("diagnostics", "diagnostics"), + load("activity", "activity?limit=50"), + load("logs", "logs?limit=100"), + load("projectBinding", "project-binding"), + load("updates", "catalog/updates"), + ]); + setData( + Object.fromEntries(loaded.map((entry) => [entry.name, entry.value])) as DashboardData, + ); + return true; + } catch (error) { + if (isDashboardUnauthorized(error)) { + endDashboardSession(); + return false; + } + throw error; + } finally { + setDataLoading(false); + } + } + + async function load(name: string, path: string) { + try { + return { name, value: await dashboardApi(path) }; + } catch (error) { + if (isDashboardUnauthorized(error)) throw error; + return { name, value: { error: error instanceof Error ? error.message : String(error) } }; + } + } + + async function startAuthorization() { + setLoading(true); + try { + const pending = await dashboardApi<{ + flowId: string; + pendingCompletionSecret: string; + intervalSeconds: number; + approvalCommand: string; + }>("login/start", { + method: "POST", + body: JSON.stringify({ clientLabel: "Browser Dashboard" }), + }); + setAuthCommand(pending.approvalCommand); + setAuthMessage( + "Run this command on the Current Host. This page will finish automatically after approval.", + ); + window.setTimeout( + () => void pollAuthorization(pending), + Math.max(1, pending.intervalSeconds || 5) * 1000, + ); + } catch (error) { + setAuthMessage(error instanceof Error ? error.message : String(error)); + setLoading(false); + } + } + + async function pollAuthorization(pending: { + flowId: string; + pendingCompletionSecret: string; + intervalSeconds: number; + }) { + try { + const result = await dashboardApi<{ status: string }>("login/poll", { + method: "POST", + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + if (result.status !== "approved") { + if (result.status === "denied" || result.status === "expired") { + setAuthCommand(""); + setAuthMessage(`Pending login was ${result.status}. Start a new browser approval.`); + setLoading(false); + return; + } + window.setTimeout( + () => void pollAuthorization(pending), + Math.max(1, pending.intervalSeconds || 5) * 1000, + ); + return; + } + const completed = await dashboardApi<{ session: DashboardSession }>("login/complete", { + method: "POST", + body: JSON.stringify({ + flowId: pending.flowId, + pendingCompletionSecret: pending.pendingCompletionSecret, + }), + }); + setSession(completed.session); + setDashboardSession(completed.session); + const refreshed = await refresh(); + if (refreshed) setLoading(false); + } catch (error) { + setAuthMessage(error instanceof Error ? error.message : String(error)); + setLoading(false); + } + } + + async function action(label: string, callback: () => Promise) { + try { + await callback(); + const refreshed = await refresh(); + if (refreshed) toast.success(label); + } catch (error) { + if (isDashboardUnauthorized(error)) { + endDashboardSession(); + return; + } + toast.error(error instanceof Error ? error.message : String(error)); + } + } + + async function logout() { + try { + await dashboardApi("logout", { method: "POST", body: "{}" }); + endDashboardSession(); + toast.success("Logged out"); + } catch (error) { + if (isDashboardUnauthorized(error)) { + endDashboardSession(); + return; + } + toast.error(error instanceof Error ? error.message : String(error)); + } + } + + function navigate(next: RouteKey, href: string) { + setRoute(next); + window.history.pushState({ route: next }, `${routeLabel(next)} · Caplets Dashboard`, href); + } + + useEffect(() => { + const onPop = () => setRoute(routeFromPath(window.location.pathname)); + window.addEventListener("popstate", onPop); + return () => window.removeEventListener("popstate", onPop); + }, []); + + useEffect(() => { + document.title = `${routeLabel(route)} · Caplets Dashboard`; + }, [route]); + + let content: ReactNode; + if (!session) { + content = ( +
+ + + + Operator Client required + + Caplets Admin Dashboard + + {authMessage} + + + + + {authCommand ? ( + <> +
+
+                    {authCommand}
+                  
+ +
+ + + ) : null} +
+
+ +
+ ); + } else { + const isDevelopmentSession = session.operatorClientId === "development_unauthenticated"; + content = ( + + + + + Skip to dashboard content + + + +
+ +
+
Caplets
+
Operator console
+
+
+
+ + + Dashboard + + + + + + +
+ + {isDevelopmentSession ? ( +
+
No-auth mode
+
+ Operator checks are bypassed locally. +
+
+ ) : ( +
+ void logout()} + > + + +
+ )} +
+
+
+ +
+ + + + +
+
+ {data.summary?.host?.baseUrl ?? "Current Host"} +
+
+ action("Dashboard refreshed", refresh)} + > + + +
+
+ +
+
+ + +
+
+
+ ); + } + + return ( + + {content} + + ); +} + +function DashboardThemeSelect() { + const { theme, resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!mounted) return; + const browserTheme = resolvedTheme === "dark" ? "dark" : "light"; + document.documentElement.style.colorScheme = browserTheme; + const themeColor = + document.querySelector('meta[name="theme-color"]') ?? + document.head.appendChild( + Object.assign(document.createElement("meta"), { name: "theme-color" }), + ); + themeColor.content = dashboardThemeColors[browserTheme]; + }, [mounted, resolvedTheme]); + + const selectedTheme: DashboardTheme = + mounted && (theme === "light" || theme === "dark" || theme === "system") ? theme : "system"; + + return ( + + ); +} + +function DashboardNavItem({ + item, + active, + onNavigate, +}: { + item: { key: RouteKey; label: string; href: string; icon: typeof HomeIcon }; + active: boolean; + onNavigate: (next: RouteKey, href: string) => void; +}) { + const { setOpenMobile } = useSidebar(); + const Icon = item.icon; + return ( + + { + event.preventDefault(); + onNavigate(item.key, item.href); + setOpenMobile(false); + }} + /> + } + > + + {item.label} + + + ); +} + +function Page({ + route, + data, + loading, + action, + session, +}: { + route: RouteKey; + data: DashboardData; + loading: boolean; + session: DashboardSession; + action: (label: string, callback: () => Promise) => Promise; +}) { + if (route === "access") return ; + if (route === "caplets") return ; + if (route === "catalog") return ; + if (route === "vault") return ; + if (route === "runtime") return ; + if (route === "activity") return ; + if (route === "settings") return ; + return ; +} + +function ConfirmationDialog({ + request, + onResolve, +}: { + request?: ConfirmationRequest; + onResolve: (confirmed: ConfirmationResult) => void; +}) { + const [typed, setTyped] = useState(""); + + useEffect(() => setTyped(""), [request]); + if (!request) return null; + + const disabled = request.expectedPhrase ? typed !== request.expectedPhrase : false; + return ( + !open && onResolve(false)}> + request.finalFocus ?? undefined}> + + {request.title} + {request.description} + + {request.expectedPhrase ? ( + + ) : null} + + + + + + + ); +} + +async function copyToClipboard(text: string, label = "Copied") { + try { + if (!navigator.clipboard?.writeText) throw new Error("Clipboard access is unavailable."); + await navigator.clipboard.writeText(text); + toast.success(label); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Copy failed."); + } +} + +function TooltipIconButton({ + label, + children, + side = "top", + size = "icon-sm", + ...props +}: Omit, "children"> & { + label: string; + children: ReactNode; + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + {children} + + } + /> + {label} + + ); +} + +function TooltipIconBadge({ + label, + children, + side = "top", + className = "", + ...props +}: Omit, "children"> & { + label: string; + children: ReactNode; + side?: "top" | "right" | "bottom" | "left"; +}) { + return ( + + + {children} + {label} + + } + /> + {label} + + ); +} + +function OverviewPage({ data, loading }: { data: DashboardData; loading: boolean }) { + const attention = data.summary?.attention ?? []; + const pendingCount = data.pending?.pendingLogins?.length ?? 0; + const updateSummary = catalogUpdateSummary(data.updates?.updates ?? []); + const runtimeStatus = data.runtime?.runtime?.status ?? data.diagnostics?.status ?? "unknown"; + const projectBindingState = data.projectBinding?.projectBinding?.state ?? "not configured"; + const vaultGrantCount = data.vault?.grants?.length ?? 0; + const inventoryCount = (data.caplets?.caplets ?? []).length; + const attentionItems = [ + ...(pendingCount + ? [ + { + label: `${pendingCount} pending approvals`, + href: routeHref("access"), + detail: "Remote clients are waiting for a role decision.", + severity: "warning" as const, + }, + ] + : []), + ...(updateSummary.severity === "warning" + ? [ + { + label: updateSummary.label, + href: routeHref("catalog"), + detail: "Catalog updates need risk review before install actions.", + severity: "warning" as const, + }, + ] + : []), + ...((projectBindingState !== "bound" && projectBindingState !== "ready" + ? [ + { + label: `Project Binding ${projectBindingState}`, + href: routeHref("runtime"), + detail: "Current Host is not attached to a local project context.", + severity: "info" as const, + }, + ] + : []) as Array<{ + label: string; + href: string; + detail: string; + severity: "warning" | "info"; + }>), + ...attention.map((item) => ({ + label: item.label, + href: routeHref("overview"), + detail: item.kind ?? "Operator action requires review.", + severity: item.severity === "warning" ? ("warning" as const) : ("info" as const), + })), + ]; + if (loading && !data.summary) return ; + return ( + + + +
+
+

Operator attention

+ + {attentionItems.length + ? "Highest-priority operator checks and review queues for this host." + : "No urgent operator actions. Review queues below are healthy or informational."} + +
+ + {attentionItems.length ? `${attentionItems.length} review items` : "All clear"} + +
+
+ + {attentionItems.length ? ( + attentionItems.map((item) => ( + +
+ {item.label} + + {item.severity === "warning" ? "Needs review" : "Inspect"} + +
+ {item.detail} +
+ )) + ) : ( + + )} +
+
+
+ + + +
+
+ + + + + + +
+
+ ); +} + +function TriageCard({ + title, + status, + detail, + href, + actionLabel, + severity, +}: { + title: string; + status: string; + detail: string; + href: string; + actionLabel: string; + severity: "ok" | "info" | "warning"; +}) { + return ( + + +
+ {title} + + {status} + +
+ {detail} +
+ + + {actionLabel} + + +
+ ); +} + +function catalogUpdateSummary(updates: Array<{ id?: string; status?: string; risk?: unknown }>) { + if (!updates.length) return { label: "No updates", severity: "ok" as const }; + const riskyCount = updates.filter((update) => update.risk !== undefined).length; + return { + label: riskyCount + ? `${updates.length} updates · risk review required` + : `${updates.length} updates`, + severity: "warning" as const, + }; +} + +function AccessPage({ + data, + loading, + action, +}: { + data: DashboardData; + loading: boolean; + action: (label: string, callback: () => Promise) => Promise; +}) { + const { confirmAction, confirmTyped } = useActionConfirm(); + const pending = data.pending?.pendingLogins ?? []; + const clients = data.clients?.clients ?? []; + if (loading && !data.clients && !data.pending) return ; + return ( + +
+ Role guide. + + Operator clients can administer the dashboard. Access clients get limited runtime + credentials without host administration. + +
+ + +

Pending logins

+ + Requests from remote browsers and clients appear here for approval. + +
+ + {pending.length ? ( + pending.map((login) => ( + + + + + + } + /> + )) + ) : ( + + )} + +
+ + +

+ Clients +

+ + Approved remote clients and their current role assignments. + +
+ +
+ {clients.length ? ( + clients.map((client) => ( + + )) + ) : ( + + )} +
+
+ {clients.length ? ( + + Approved remote clients + + + Client + Role + + Actions + + + + + {clients.map((client) => ( + + {client.clientLabel || client.clientId} + + {client.role} + + + + + + ))} + +
+ ) : ( + + )} +
+
+
+
+ ); +} + +function ClientCard({ + client, + action, + confirmAction, + confirmTyped, +}: { + client: Record; + action: (label: string, callback: () => Promise) => Promise; + confirmAction: (title: string, description: string) => Promise; + confirmTyped: (title: string, description: string, expectedPhrase: string) => Promise; +}) { + return ( +
+
+

{client.clientLabel || client.clientId}

+ {client.role} +
+
+ +
+
+ ); +} + +function ClientActions({ + client, + action, + confirmAction, + confirmTyped, +}: { + client: Record; + action: (label: string, callback: () => Promise) => Promise; + confirmAction: (title: string, description: string) => Promise; + confirmTyped: (title: string, description: string, expectedPhrase: string) => Promise; +}) { + const label = client.clientLabel || client.clientId; + const nextRole = client.role === "operator" ? "access" : "operator"; + return ( + <> + + + + ); +} + +function CapletsPage({ + data, + loading, + action, +}: { + data: DashboardData; + loading: boolean; + action: (label: string, callback: () => Promise) => Promise; +}) { + const { confirmTyped } = useActionConfirm(); + const caplets = data.caplets?.caplets ?? []; + const updateRisks = new Map( + (data.updates?.updates ?? []).map((update) => [String(update.id), update]), + ); + if (loading && !data.caplets) return ; + return ( + + + +
+ {caplets.length ? ( + caplets.map((caplet) => { + const capletId = caplet.id ?? caplet.name ?? "caplet"; + const update = updateRisks.get(String(capletId)); + return ( + } + actions={ + update ? ( + { + if ( + !(await confirmTyped( + "Review Caplet update?", + capletUpdateReviewSummary(caplet, update), + `update ${capletId}`, + )) + ) + return; + await action("Update requested", () => + dashboardApi("catalog/update", { + method: "POST", + body: JSON.stringify({ + capletId, + acknowledgeRiskIncrease: true, + }), + }), + ); + }} + > + + + ) : ( + + + + ) + } + /> + ); + }) + ) : ( + + )} +
+
+
+
+ ); +} + +function CapletUpdateReadiness({ + caplet, + update, +}: { + caplet: CapletRecord; + update?: { id?: string; status?: string; risk?: unknown }; +}) { + const capletName = caplet.id ?? caplet.name ?? "caplet"; + const sourceLabel = caplet.source ? `Source ${caplet.source}` : "Source local/config"; + const lockLabel = caplet.updateState ? `Lock ${caplet.updateState}` : "Lock unknown"; + const authRequired = dashboardBoolean(caplet.authRequired); + const setupRequired = dashboardBoolean(caplet.setupRequired); + const projectBindingRequired = dashboardBoolean(caplet.projectBindingRequired); + const authLabel = authRequired ? "Auth required" : "No auth required"; + const setupLabel = setupRequired ? "Setup required" : "No setup required"; + const bindingLabel = projectBindingRequired + ? "Project Binding required" + : "No Project Binding required"; + const updateLabel = update ? `Update ${update.status ?? "available"}` : "No update available"; + const riskSummary = riskSummaryLines(update?.risk); + return ( +
+

{caplet.title ?? caplet.description ?? caplet.kind}

+
+ + + + + + + + + + + + + + + + + {update ? : } + + {riskSummary.summary ? ( + + + + ) : null} +
+
+ ); +} + +function capletUpdateReviewSummary( + caplet: CapletRecord, + update: { id?: string; status?: string; risk?: unknown }, +): string { + const riskSummary = riskSummaryLines(update.risk); + const capletId = caplet.id ?? caplet.name ?? "caplet"; + const source = caplet.source ?? "local/config"; + const updateState = caplet.updateState ?? "unknown"; + const readiness = `${dashboardBoolean(caplet.authRequired) ? "auth required" : "no auth"}, ${dashboardBoolean(caplet.setupRequired) ? "setup required" : "no setup"}, ${dashboardBoolean(caplet.projectBindingRequired) ? "Project Binding required" : "no Project Binding requirement"}`; + return [ + `${capletId} has an installable update review.`, + `Source: ${source}.`, + `Lock state: ${updateState}.`, + `Readiness: ${readiness}.`, + `Update state: ${update.status ?? "available"}.`, + riskSummary.summary + ? `Risk summary: ${riskSummary.summary}.` + : "Risk summary: no elevated risk reported.", + riskSummary.details.length ? `Risk details: ${riskSummary.details.join("; ")}.` : "", + "Type the exact Caplet id only after reviewing this update metadata.", + ] + .filter(Boolean) + .join(" "); +} + +function riskSummaryLines(risk: unknown): { summary?: string; details: string[] } { + if (!risk || typeof risk !== "object") { + return typeof risk === "string" ? { summary: risk, details: [] } : { details: [] }; + } + const record = risk as Record; + const details: string[] = []; + if (Array.isArray(record.backendFamilies) && record.backendFamilies.length) { + details.push(`Backends ${record.backendFamilies.join(", ")}`); + } + if (typeof record.safety === "string") + details.push(`Safety ${record.safety.replace(/_/gu, " ")}`); + if (typeof record.mutating === "boolean") + details.push(record.mutating ? "Mutates external state" : "Read-focused"); + if (typeof record.destructive === "boolean" && record.destructive) + details.push("Destructive behavior possible"); + if (typeof record.projectBindingRequired === "boolean" && record.projectBindingRequired) { + details.push("Project Binding required"); + } + if (typeof record.bodyHash === "string") + details.push(`Manifest hash ${record.bodyHash.slice(0, 18)}…`); + return { + summary: details.length ? details.slice(0, 2).join(" · ") : "Review update details", + details, + }; +} + +type CatalogEntry = { + id: string; + name: string; + description: string; + tags?: string[]; + source?: { repository?: string }; + sourcePath?: string; + trustLevel?: string; + setupReadiness?: string; + authReadiness?: string; + projectBindingReadiness?: string; + workflow?: { label?: string; kind?: string }; + installCommand?: { text?: string; copyable?: boolean }; + warnings?: Array<{ code?: string; label: string; message?: string; severity?: string }>; + icon?: { type?: string; url?: string }; + contentMarkdown?: string; + resolvedRevision?: string; + indexedContentHash?: string; +}; + +type CatalogSearchResponse = { entries?: CatalogEntry[]; error?: string }; +type CatalogDetailResponse = { + entry?: CatalogEntry; + setupActions?: Array<{ kind: string; label: string; required: boolean }>; + error?: string; +}; + +const catalogSearchEndpoint = "catalog/search"; +const catalogDetailEndpoint = "catalog/detail"; +const catalogInstallEndpoint = "catalog/install"; + +function CatalogPage({ + data, + action, +}: { + data: DashboardData; + action: (label: string, callback: () => Promise) => Promise; +}) { + return ; +} + +function CatalogMirrorPage({ + data, + action, +}: { + data: DashboardData; + action: (label: string, callback: () => Promise) => Promise; +}) { + const { confirmTyped } = useActionConfirm(); + const [source, setSource] = useState("official"); + const [query, setQuery] = useState(""); + const [scope, setScope] = useState("all"); + const [setup, setSetup] = useState("all"); + const [tag, setTag] = useState("all"); + const [sort, setSort] = useState("rank"); + const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(); + const [detail, setDetail] = useState(); + const [loading, setLoading] = useState(true); + const [installing, setInstalling] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + let cancelled = false; + setLoading(true); + const timer = window.setTimeout(() => { + const params = new URLSearchParams({ source, q: query, limit: "100" }); + dashboardApi(`${catalogSearchEndpoint}?${params}`) + .then((result) => { + if (cancelled) return; + const nextEntries = result.entries ?? []; + setEntries(nextEntries); + setSelected((current) => + current && nextEntries.some((entry) => entry.id === current.id) ? current : undefined, + ); + setError(undefined); + }) + .catch((searchError) => { + if (cancelled) return; + setEntries([]); + setSelected(undefined); + setError(searchError instanceof Error ? searchError.message : String(searchError)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + }, 180); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [source, query]); + + useEffect(() => { + if (!selected) { + setDetail(undefined); + return; + } + const params = new URLSearchParams({ source, id: selected.id }); + dashboardApi(`${catalogDetailEndpoint}?${params}`) + .then(setDetail) + .catch((detailError) => + setDetail({ + error: detailError instanceof Error ? detailError.message : String(detailError), + }), + ); + }, [source, selected]); + + const tags = useMemo( + () => [...new Set(entries.flatMap((entry) => entry.tags ?? []))].sort(), + [entries], + ); + const visibleEntries = useMemo(() => { + const filtered = entries.filter((entry) => { + const trustMatches = scope === "all" || entry.trustLevel === scope; + const setupMatches = setup === "all" || entry.setupReadiness === setup; + const tagMatches = tag === "all" || (entry.tags ?? []).includes(tag); + return trustMatches && setupMatches && tagMatches; + }); + return [...filtered].sort((left, right) => + sort === "name" ? left.name.localeCompare(right.name) : rankEntry(right) - rankEntry(left), + ); + }, [entries, scope, setup, sort, tag]); + + async function install(entry: CatalogEntry) { + const expected = `install ${entry.id}`; + if (!(await confirmTyped("Install Caplet?", installReviewSummary(entry), expected))) { + return; + } + setInstalling(entry.id); + try { + await action(`Installed ${entry.name}`, () => + dashboardApi(catalogInstallEndpoint, { + method: "POST", + body: JSON.stringify({ source, capletId: entry.id }), + }), + ); + } finally { + setInstalling(undefined); + } + } + + return ( + +
+
+
+
+
+
Caplets Catalog
+
+ {visibleEntries.length} Caplets · Install button actions replace install commands +
+
+
+ +
+ +
+ Not security-reviewed. + Inspect Caplets before installing. +
+ + + +
+
+ Browse catalog + + Install readiness: {data.updates?.ready === false ? data.updates.reason : "ready"} + +
+
+ + setQuery(event.target.value)} + type="search" + placeholder="Search Caplets" + aria-label="Search Caplets" + /> +
+
+ + +
+ +
+ {loading + ? "Loading catalog results" + : error + ? "Catalog results failed to load" + : `${visibleEntries.length} catalog results loaded`} +
+ {error ? : null} + {loading ? ( + + ) : ( + { + setSelected(entry); + window.setTimeout(() => { + const panel = document.getElementById("catalog-detail-panel"); + panel?.scrollIntoView({ block: "start" }); + if (panel instanceof HTMLElement) panel.focus(); + }, 0); + }} + onInstall={(entry) => { + setSelected(entry); + window.setTimeout(() => { + const panel = document.getElementById("catalog-detail-panel"); + panel?.scrollIntoView({ block: "start" }); + if (panel instanceof HTMLElement) panel.focus(); + }, 0); + }} + /> + )} +
+
+ + void install(entry)} + /> +
+ ); +} + +function CatalogFilterBar({ + scope, + setup, + tag, + sort, + tags, + onScopeChange, + onSetupChange, + onTagChange, + onSortChange, +}: { + scope: string; + setup: string; + tag: string; + sort: string; + tags: string[]; + onScopeChange: (value: string) => void; + onSetupChange: (value: string) => void; + onTagChange: (value: string) => void; + onSortChange: (value: string) => void; +}) { + return ( +
+ + + + +
+ ); +} + +function CatalogSelect({ + label, + value, + items, + onValueChange, +}: { + label: string; + value: string; + items: string[]; + onValueChange: (value: string) => void; +}) { + return ( + + ); +} + +function CatalogLegend() { + const items = [ + { label: "Official", icon: ShieldCheckIcon }, + { label: "Community", icon: ComputerIcon }, + { label: "External changes", icon: ExternalLinkIcon }, + { label: "Auth", icon: KeyIcon }, + { label: "Setup", icon: SettingsIcon }, + { label: "Binding", icon: LinkIcon }, + { label: "Unknown readiness", icon: AlertTriangleIcon }, + ]; + return ( +
+ {items.map((item) => { + const Icon = item.icon; + return ( + + + ); + })} +
+ ); +} + +function CatalogResultGrid({ + rows, + selectedId, + installing, + loading, + onSelect, + onInstall, +}: { + rows: CatalogEntry[]; + selectedId?: string; + installing?: string; + loading: boolean; + onSelect: (entry: CatalogEntry) => void; + onInstall: (entry: CatalogEntry) => void; +}) { + if (!rows.length) { + return ( +
+

No matching Caplets

+

+ Reset filters or search for the service, workflow, or setup requirement you need. +

+
+ ); + } + + return ( +
+
+ {rows.map((row) => ( +
+
+ +
+ + {selectedId === row.id ? ( + + + ) : null} +
+ {row.source?.repository ?? "local/source"} +
+
+
+

{row.description}

+
+ +
+ +
+ ))} +
+
+
+
+ Caplet + Description + Installs + Status + Install +
+
+ {rows.map((row) => ( +
+
+ +
+ +
+ {row.source?.repository ?? "local/source"} +
+
+
+

+ {row.description} +

+
+ {installCountDisplay(row)} +
+
+ +
+
+ +
+
+ ))} +
+
+
+
+ ); +} + +function CatalogStatusBadges({ row }: { row: CatalogEntry }) { + return ( + <> + + {row.trustLevel ?? "community"} + + {(row.warnings ?? []).slice(0, 2).map((warning) => ( + + {warning.label} + + ))} + + ); +} + +function CatalogReadinessReview({ + entry, + setupActions, +}: { + entry: CatalogEntry; + setupActions: Array<{ kind: string; label: string; required: boolean }>; +}) { + const checks = [ + { label: "Trust", value: entry.trustLevel ?? "community" }, + { label: "Auth", value: entry.authReadiness ?? "unknown" }, + { label: "Setup", value: entry.setupReadiness ?? "unknown" }, + { label: "Project Binding", value: entry.projectBindingReadiness ?? "unknown" }, + ]; + return ( +
+

+ Installation readiness review +

+

+ The install confirmation repeats the exact Caplet id after you inspect these trust and setup + checks. +

+
+ {checks.map((check) => ( +
+
{check.label}
+
{check.value}
+
+ ))} +
+ {setupActions.length ? ( +
    + {setupActions.map((setupAction) => ( +
  • + + {setupAction.required ? "Required" : "Optional"} + + {setupAction.label} +
  • + ))} +
+ ) : null} +
+ ); +} + +function CatalogDetailPanel({ + entry, + error, + setupActions, + installing, + onInstall, +}: { + entry?: CatalogEntry; + error?: string; + setupActions: Array<{ kind: string; label: string; required: boolean }>; + installing?: string; + onInstall: (entry: CatalogEntry) => void; +}) { + if (error) return ; + if (!entry) { + return ( + + +

Inspect a Caplet

+

+ Select a result to review trust, setup requirements, and manifest details before you + install it. +

+
+
+ ); + } + const markdown = trimFrontmatter(entry.contentMarkdown ?? ""); + return ( +
+ + +
+ +
+
+ + {entry.trustLevel ?? "community"} + + {entry.source?.repository ?? "local/source"} +
+

{entry.name}

+ {entry.description} +
+
+ +
+ + + +
+ + CAPLET.md + + {markdown ? ( +
+                {markdown}
+              
+ ) : ( +
+ +
+ )} +
+
+
+ + +

Record

+
+ +
+ + + + + + + + +
+ {setupActions.length ? ( +
+ {setupActions.map((setupAction) => ( + + {setupAction.label} + + ))} +
+ ) : null} +
+
+
+ ); +} + +function CatalogIcon({ entry, large = false }: { entry: CatalogEntry; large?: boolean }) { + const [failed, setFailed] = useState(false); + useEffect(() => setFailed(false), [entry.icon?.url]); + const iconUrl = entry.icon?.url; + const iconBlocked = iconUrl === "https://www.cloudflare.com/favicon.ico"; + if (iconUrl && !failed && !iconBlocked) { + return ( + setFailed(true)} + /> + ); + } + return ( + + ); +} + +function SafetyWarnings({ warnings }: { warnings: CatalogEntry["warnings"] }) { + if (!warnings?.length) return null; + return ( + + + Inspect before installing + +
    + {warnings.map((warning) => ( +
  • + {warning.label} + {warning.message ? — {warning.message} : null} +
  • + ))} +
+
+
+ ); +} + +function CatalogLoadingRows() { + return ( +
+ {[0, 1, 2, 3].map((item) => ( +
+ ))} +
+ ); +} + +function CatalogError({ message }: { message: string }) { + return ( + + + Catalog unavailable + {message} + + ); +} + +function RecordRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function rankEntry(entry: CatalogEntry): number { + return entry.trustLevel === "official" ? 1 : 0; +} + +function installCountDisplay(_entry: CatalogEntry): string { + return "<10"; +} + +function installReviewSummary(entry: CatalogEntry): string { + const warnings = entry.warnings?.length + ? entry.warnings.map((warning) => warning.label).join(", ") + : "none"; + return [ + `${entry.name} can add local capabilities.`, + `Trust: ${entry.trustLevel ?? "community"}.`, + `Auth: ${entry.authReadiness ?? "unknown"}.`, + `Setup: ${entry.setupReadiness ?? "unknown"}.`, + `Project Binding: ${entry.projectBindingReadiness ?? "unknown"}.`, + `Warnings: ${warnings}.`, + "Type the exact Caplet id only after reviewing this readiness summary.", + ].join(" "); +} + +function catalogLabel(value: string): string { + if (value === "all") return "All"; + if (value === "rank") return "Most relevant"; + return value.replace(/_/gu, " ").replace(/^./u, (char) => char.toUpperCase()); +} + +function vaultKeyPresentation(rawKey: string): { label: string; kind: string; sensitive: boolean } { + const upper = rawKey.toUpperCase(); + const sensitive = /(TOKEN|SECRET|PASSWORD|PRIVATE|API_KEY|CLIENT_SECRET)/u.test(upper); + const kind = /(PATH|DIR|ROOT|FILE)/u.test(upper) + ? "Local path" + : sensitive + ? "Credential" + : "Stored value"; + if (!sensitive) return { label: rawKey, kind, sensitive }; + const masked = rawKey.replace(/(TOKEN|SECRET|PASSWORD|KEY)$/iu, "••••"); + return { + label: masked === rawKey ? `${rawKey.slice(0, Math.min(rawKey.length, 14))}••••` : masked, + kind, + sensitive, + }; +} + +function trimFrontmatter(markdown: string): string { + return markdown.replace(/^---[\s\S]*?---\s*/u, "").trim(); +} +function VaultPage({ + data, + loading, + action, +}: { + data: DashboardData; + loading: boolean; + action: (label: string, callback: () => Promise) => Promise; +}) { + const confirm = useConfirm(); + const { confirmTyped } = useActionConfirm(); + const [key, setKey] = useState(""); + const [value, setValue] = useState(""); + const [revealed, setRevealed] = useState<{ key: string; value: string; expiresAt: number }>(); + const [now, setNow] = useState(Date.now()); + const values = data.vault?.values ?? []; + useEffect(() => { + if (!revealed) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [revealed]); + useEffect(() => { + if (revealed && now >= revealed.expiresAt) setRevealed(undefined); + }, [now, revealed]); + async function setVaultValue() { + if (!key || !value) return; + const replacing = values.some((entry) => String(entry.key) === key); + if ( + replacing && + !(await confirmTyped( + "Replace existing Vault value?", + `${key} already exists. Replacing it overwrites the stored secret value.`, + `replace ${key}`, + )) + ) + return; + await action(replacing ? "Vault value replaced" : "Vault value saved", () => + dashboardApi("vault/values", { + method: "POST", + body: JSON.stringify({ key, value, force: replacing }), + }), + ); + } + if (loading && !data.vault) return ; + return ( + + + +

Set value

+ + Store or replace one value at a time for the Current Host. + +
+ +
{ + event.preventDefault(); + void setVaultValue(); + }} + > + + + +

+ Press Enter to save. Existing values are overwritten only after this form submits. +

+
+
+
+ {revealed ? ( + setRevealed(undefined)} + /> + ) : null} + + +

Values

+ + Credential names are softened until you deliberately inspect or reveal them. + +
+ + {values.length ? ( + values.map((entry) => { + const rawKey = String(entry.key); + const presentation = vaultKeyPresentation(rawKey); + return ( + + {presentation.label} + + {presentation.kind} + +
+ } + detail={ + presentation.sensitive + ? `${entry.valueBytes ?? 0} bytes · full key hidden until explicit reveal` + : `${entry.valueBytes ?? 0} bytes` + } + actions={ + <> + { + const confirmation = await confirm({ + title: "Reveal secret value?", + description: `${rawKey} will be visible in a dedicated panel for 30 seconds in this browser session only.`, + expectedPhrase: `reveal ${rawKey}`, + confirmLabel: "Reveal for 30s", + destructive: true, + }); + if (confirmation !== `reveal ${rawKey}`) return; + await action("Vault value revealed", async () => { + const revealed = await dashboardApi<{ value: string }>("vault/reveal", { + method: "POST", + body: JSON.stringify({ + key: entry.key, + confirmation, + }), + }); + setRevealed({ + key: rawKey, + value: revealed.value, + expiresAt: Date.now() + 30_000, + }); + }); + }} + > + + + { + if ( + !(await confirmTyped( + "Delete Vault value?", + `${rawKey} cannot be recovered from the dashboard after deletion.`, + `delete ${rawKey}`, + )) + ) + return; + await action("Vault value deleted", () => + dashboardApi(`vault/values/${entry.key}/delete`, { + method: "POST", + body: "{}", + }), + ); + }} + > + + + + } + /> + ); + }) + ) : ( + + )} + + + + ); +} + +function SecretRevealPanel({ + secret, + secondsRemaining, + onHide, +}: { + secret: { key: string; value: string }; + secondsRemaining: number; + onHide: () => void; +}) { + return ( + + + Secret reveal: {secret.key} + + Visible for {secondsRemaining} seconds. Hide it when you are done. + + + +
+          {secret.value}
+        
+
+ void copyToClipboard(secret.value, "Secret copied")} + > + + + + + +
+
+
+ ); +} + +function humanizeToken(value: string): string { + return value.replace(/[_-]+/gu, " ").replace(/\b\w/gu, (char) => char.toUpperCase()); +} + +function formatDashboardTimestamp(value: unknown): { display: string; exact: string } { + const exact = String(value ?? ""); + const parsed = Date.parse(exact); + if (!exact || Number.isNaN(parsed)) return { display: exact || "Unknown", exact }; + return { + display: new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }).format(new Date(parsed)), + exact, + }; +} + +function activityEntryView(entry: Record) { + const target = (entry.target as Record | undefined) ?? {}; + const timestamp = formatDashboardTimestamp(entry.createdAt); + return { + id: String(entry.id ?? ""), + action: humanizeToken(String(entry.action ?? "Activity")), + actionToken: String(entry.action ?? "activity"), + actor: String(entry.actorClientId ?? "Unknown actor"), + outcome: humanizeToken(String(entry.outcome ?? "unknown")), + targetType: humanizeToken(String(target.type ?? "target")), + targetId: String(target.id ?? "Unknown target"), + timestamp, + }; +} + +function RuntimePage({ + data, + loading, + action, +}: { + data: DashboardData; + loading: boolean; + action: (label: string, callback: () => Promise) => Promise; +}) { + const confirm = useConfirm(); + const runtime = data.runtime?.runtime ?? {}; + const checks = data.diagnostics?.checks ?? []; + const runtimeStatus = String(runtime.status ?? "unknown"); + const healthy = runtimeStatus === "ok" || runtimeStatus === "healthy"; + if (loading && !data.runtime) return ; + return ( + +
+ + +
+
+

+ {healthy ? "Runtime healthy" : "Runtime needs review"} +

+ + Runtime status: {humanizeToken(runtimeStatus)}. + +
+ + {humanizeToken(runtimeStatus)} + +
+
+ +
+ + +
+ + + Daemon actions can interrupt active work. + + Restarting the runtime interrupts active Current Host requests while the daemon + restarts. + + +
+ + + View activity log + +
+
+
+ + +

Diagnostics

+ Runtime and dashboard checks for the Current Host. +
+ + {checks.length ? ( + checks.map((check) => ( + + {humanizeToken(String(check.id ?? "check"))} + + {String(check.id ?? "check")} + +
+ } + detail={`Status: ${humanizeToken(String(check.status ?? "unknown"))}`} + /> + )) + ) : ( + + )} + + + +
+ ); +} + +function ActivityPage({ data, loading }: { data: DashboardData; loading: boolean }) { + const [query, setQuery] = useState(""); + const [outcome, setOutcome] = useState("all"); + const entryViews = ((data.activity?.entries ?? []) as Array>).map( + activityEntryView, + ); + const filteredEntries = entryViews.filter((entry) => { + const outcomeMatches = outcome === "all" || entry.outcome.toLowerCase() === outcome; + const haystack = [entry.action, entry.actor, entry.targetType, entry.targetId, entry.id] + .join(" ") + .toLowerCase(); + return outcomeMatches && (!query || haystack.includes(query.toLowerCase())); + }); + const filtersActive = query.trim().length > 0 || outcome !== "all"; + if (loading && !data.activity) return ; + return ( + + + +
+
+

Recent operator events

+ + Search or filter the audit log when you need to trace who changed what and when. + +
+
+ setQuery(event.target.value)} + type="search" + placeholder="Search actions, actors, or targets" + aria-label="Search activity" + /> + +
+
+
+ + {data.activity?.error ? ( + + + Activity log unavailable + {data.activity.error} + + ) : filteredEntries.length ? ( + <> +
+ {filteredEntries.map((entry) => ( +
+
+

{entry.action}

+ + {entry.outcome} + +
+

Actor: {entry.actor}

+

+ Target: {entry.targetType} · {entry.targetId} +

+

Time: {entry.timestamp.display}

+
+ + Details + +
+ {entry.actionToken} + {entry.timestamp.exact} + {entry.id} +
+
+
+ ))} +
+
+ + Recent redacted operator activity + + + Action + Outcome + Actor + Target + Time + + + + {filteredEntries.map((entry) => ( + + +
+ {entry.action} + + {entry.actionToken} + +
+
+ {entry.outcome} + {entry.actor} + + {entry.targetType} · {entry.targetId} + + +
+ {entry.timestamp.display} + + {entry.timestamp.exact} + +
+
+
+ ))} +
+
+
+ + ) : entryViews.length ? ( +
+ No activity matches the current search or outcome filter. + +
+ ) : ( + + )} +
+
+
+ ); +} + +function SettingsPage({ session, summary }: { session: DashboardSession; summary?: Summary }) { + const isDevelopmentSession = session.operatorClientId === "development_unauthenticated"; + return ( + + + +
+
+

Dashboard session

+ + {isDevelopmentSession + ? "Development no-auth mode is active for this browser session." + : "This browser is using an approved operator session."} + +
+ + {isDevelopmentSession ? "No-auth development bypass" : "Operator session"} + +
+
+ + + + + {isDevelopmentSession + ? "Operator checks are bypassed locally." + : "Approved operator access."} + + + {isDevelopmentSession + ? "Requests still target the Current Host, but browser approval and logout enforcement are bypassed for local iteration." + : "This browser can administer the Current Host until the session expires or is revoked."} + + +
+ + + + +
+
+ + void copyToClipboard(session.operatorClientId, "Operator client ID copied") + } + > + + + {isDevelopmentSession ? ( + + ) : null} +
+ {isDevelopmentSession ? ( +

+ Restart the server without no-auth mode to require browser approval again. Manage + future client approvals from Access. +

+ ) : null} +
+
+
+ ); +} + +function PageFrame({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +function Metric({ title, value }: { title: string; value: string }) { + return ( + + + {title} + {value} + + + ); +} + +function DashboardLoadingState({ title }: { title: string }) { + return ( + +
+ {[0, 1, 2].map((item) => ( + + + + + + + ))} +
+ + + {[0, 1, 2].map((item) => ( + + ))} + + +
+ ); +} + +function Row({ + title, + detail, + actions, +}: { + title: React.ReactNode; + detail?: React.ReactNode; + actions?: React.ReactNode; +}) { + return ( +
+
+
{title}
+ {detail ? ( +
{detail}
+ ) : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ ); +} + +function activityOutcomeLabel(value: string): string { + if (value === "all") return "All outcomes"; + return humanizeToken(value); +} + +function EmptyLine({ text }: { text: string }) { + return ( +
+ + {text} +
+ ); +} + +export function routeFromPath(pathname: string): RouteKey { + const basePath = dashboardBasePath(pathname); + const normalizedPathname = pathname.replace(/\/+$/u, ""); + const relativePath = normalizedPathname.startsWith(basePath) + ? normalizedPathname.slice(basePath.length) + : normalizedPathname; + const segment = + relativePath + .replace(/^\/+|\/+$/gu, "") + .split("/") + .shift() || "overview"; + return routes.some((route) => route.key === segment) ? (segment as RouteKey) : "overview"; +} diff --git a/apps/dashboard/src/components/DashboardHead.astro b/apps/dashboard/src/components/DashboardHead.astro new file mode 100644 index 00000000..f4163d4c --- /dev/null +++ b/apps/dashboard/src/components/DashboardHead.astro @@ -0,0 +1,85 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} · Caplets Dashboard + diff --git a/apps/dashboard/src/components/ui/alert.tsx b/apps/dashboard/src/components/ui/alert.tsx new file mode 100644 index 00000000..42ee8606 --- /dev/null +++ b/apps/dashboard/src/components/ui/alert.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + role, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx new file mode 100644 index 00000000..e1997af4 --- /dev/null +++ b/apps/dashboard/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} + +export { Badge, badgeVariants }; diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx new file mode 100644 index 00000000..3badc73a --- /dev/null +++ b/apps/dashboard/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none motion-reduce:transition-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "min-h-11 gap-1.5 px-3 md:h-8 md:min-h-0 md:px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "min-h-11 gap-1 rounded-[min(var(--radius-md),10px)] px-3 text-xs md:h-6 md:min-h-0 md:px-2 in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "min-h-11 gap-1 rounded-[min(var(--radius-md),12px)] px-3 text-[0.8rem] md:h-7 md:min-h-0 md:px-2.5 in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "min-h-11 gap-1.5 px-3 md:h-9 md:min-h-0 md:px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-11 md:size-8", + "icon-xs": + "size-11 rounded-[min(var(--radius-md),10px)] md:size-6 in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-11 rounded-[min(var(--radius-md),12px)] md:size-7 in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-11 md:size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/dashboard/src/components/ui/card.tsx b/apps/dashboard/src/components/ui/card.tsx new file mode 100644 index 00000000..a73bb760 --- /dev/null +++ b/apps/dashboard/src/components/ui/card.tsx @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 00000000..76f08034 --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,166 @@ +"use client"; + +import * as React from "react"; +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; + +const focusableSelector = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + +function trapTabKey(event: React.KeyboardEvent, container: HTMLElement | null) { + if (event.key !== "Tab" || !container) return; + const focusable = Array.from(container.querySelectorAll(focusableSelector)).filter( + (element) => element.offsetParent !== null || element === document.activeElement, + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + onKeyDown, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + const contentRef = React.useRef(null); + return ( + + + { + trapTabKey(event, contentRef.current); + onKeyDown?.(event); + }} + {...props} + > + {children} + {showCloseButton && ( + } + > + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + }>Close + )} +
+ ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/dashboard/src/components/ui/input.tsx b/apps/dashboard/src/components/ui/input.tsx new file mode 100644 index 00000000..2e2a7452 --- /dev/null +++ b/apps/dashboard/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { Input as InputPrimitive } from "@base-ui/react/input"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/dashboard/src/components/ui/select.tsx b/apps/dashboard/src/components/ui/select.tsx new file mode 100644 index 00000000..685b6bea --- /dev/null +++ b/apps/dashboard/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client"; + +import * as React from "react"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; + +import { cn } from "@/lib/utils"; +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"; + +const Select = SelectPrimitive.Root; + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + + ); +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ); +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default"; +}) { + return ( + + {children} + } + /> + + ); +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ); +} + +function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/apps/dashboard/src/components/ui/separator.tsx b/apps/dashboard/src/components/ui/separator.tsx new file mode 100644 index 00000000..f2c55c81 --- /dev/null +++ b/apps/dashboard/src/components/ui/separator.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) { + return ( + + ); +} + +export { Separator }; diff --git a/apps/dashboard/src/components/ui/sheet.tsx b/apps/dashboard/src/components/ui/sheet.tsx new file mode 100644 index 00000000..d624e5ce --- /dev/null +++ b/apps/dashboard/src/components/ui/sheet.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; + +const focusableSelector = + "button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])"; + +function trapTabKey(event: React.KeyboardEvent, container: HTMLElement | null) { + if (event.key !== "Tab" || !container) return; + const focusable = Array.from(container.querySelectorAll(focusableSelector)).filter( + (element) => element.offsetParent !== null || element === document.activeElement, + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } +} +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return ; +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return ; +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return ; +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return ; +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ); +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + onKeyDown, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left"; + showCloseButton?: boolean; +}) { + const contentRef = React.useRef(null); + return ( + + + { + trapTabKey(event, contentRef.current); + onKeyDown?.(event); + }} + {...props} + > + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ); +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ); +} + +function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) { + return ( + + ); +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +}; diff --git a/apps/dashboard/src/components/ui/sidebar.tsx b/apps/dashboard/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..1b1fd823 --- /dev/null +++ b/apps/dashboard/src/components/ui/sidebar.tsx @@ -0,0 +1,769 @@ +"use client"; + +import * as React from "react"; +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { PanelLeftIcon } from "lucide-react"; + +const SIDEBAR_COOKIE_NAME = "sidebar_state"; +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; +const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH_MOBILE = "18rem"; +const SIDEBAR_WIDTH_ICON = "3rem"; +const SIDEBAR_KEYBOARD_SHORTCUT = "b"; + +function focusSidebarContent(id: string, attempt = 0) { + const target = document.getElementById(id)?.querySelector("a, button"); + if (target) { + target.focus(); + return; + } + if (attempt < 6) { + window.setTimeout(() => focusSidebarContent(id, attempt + 1), 50); + } +} + +function escapeStartedInNestedPopup(event: KeyboardEvent, sidebarId?: string): boolean { + if (event.defaultPrevented) return true; + if (!(event.target instanceof Element)) return false; + const popup = event.target.closest( + '[data-slot="dialog-content"], [data-slot="sheet-content"], [data-slot="select-content"], [role="dialog"]', + ); + if (!(popup instanceof HTMLElement)) return false; + return !(sidebarId && popup.id === sidebarId && popup.dataset.mobile === "true"); +} + +type SidebarContextProps = { + state: "expanded" | "collapsed"; + open: boolean; + setOpen: (open: boolean) => void; + openMobile: boolean; + setOpenMobile: (open: boolean) => void; + isMobile: boolean; + toggleSidebar: () => void; +}; + +const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const isMobile = useIsMobile(); + const [openMobile, setOpenMobile] = React.useState(false); + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen); + const open = openProp ?? _open; + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value; + if (setOpenProp) { + setOpenProp(openState); + } else { + _setOpen(openState); + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; + }, + [setOpenProp, open], + ); + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]); + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + toggleSidebar(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]); + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed"; + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar], + ); + + return ( + +
+ {children} +
+
+ ); +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + id, + style, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right"; + variant?: "sidebar" | "floating" | "inset"; + collapsible?: "offcanvas" | "icon" | "none"; +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + + React.useEffect(() => { + if (!isMobile || !openMobile) return; + if (id) focusSidebarContent(id); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || escapeStartedInNestedPopup(event, id)) return; + event.preventDefault(); + setOpenMobile(false); + if (id) { + document.querySelector(`[aria-controls="${id}"]`)?.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [id, isMobile, openMobile, setOpenMobile]); + + if (collapsible === "none") { + return ( +
+ {children} +
+ ); + } + + if (isMobile) { + return ( + { + setOpenMobile(nextOpen); + if (!nextOpen && id) { + window.setTimeout(() => { + document.querySelector(`[aria-controls="${id}"]`)?.focus(); + }, 0); + } + }} + > + {openMobile ? ( + + document.querySelector("#dashboard-sidebar a, #dashboard-sidebar button") + } + finalFocus={() => + document.querySelector("[aria-controls='dashboard-sidebar']") + } + > + + Sidebar + Displays the mobile sidebar. + +
+ {children} +
+
+ ) : null} +
+ ); + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ); +} + +function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { + const { isMobile, open, openMobile, toggleSidebar } = useSidebar(); + const expanded = isMobile ? openMobile : open; + + return ( + + ); +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar(); + + return ( +