Files
arcadia-admin/app/components/layout/app-shell.tsx
jules 20c592dfa7 admin: completeness + UI consistency pass
Arcadia wiring:
- home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context
- profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits
- session: drop unused signIn mock, add updateSessionUser, refresh tests
- profile schema: drop redundant Profile.name/email (session is the source of truth)
- routes: delete orphaned resources route + lib

Auth flows that previously 404'd:
- /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui
- shared AuthShell + AuthBrand wrapper

Assistant tools (admin-tools.ts):
- +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role
- list_memberships gains user_id filter for "tenants this user belongs to" queries
- search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used

UI consistency:
- new PageHeader component
- AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content
- removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects)
- stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6)
- migrated home + tenants to PageHeader

arcadia-search ergonomics:
- scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local
- README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs
- .env.local now gitignored

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:37:31 +10:00

592 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router"
import {
Bell,
LayoutDashboard,
Boxes,
Activity,
Building2,
Sparkles,
Bot,
BookOpen,
Settings,
Search,
PanelLeftClose,
PanelLeftOpen,
User as UserIcon,
LogOut,
HelpCircle,
Menu,
Play,
HardDrive,
Users as UsersIcon,
KeyRound,
Webhook as WebhookIcon,
CalendarClock,
Gauge,
UserCheck,
Network,
ShieldCheck,
Megaphone,
AlertOctagon,
// CREMA:NAV-ICONS
} from "lucide-react"
import {
useBrand,
useUser,
type Brand,
type User,
} from "~/lib/identity"
import {
Appbar,
AppbarActions,
AppbarSpacer,
AppbarTitle,
} from "~/components/layout/appbar"
import { ThemeToggle } from "~/components/layout/theme-toggle"
import { BackgroundPicker } from "~/components/layout/background-picker"
import { FontSizePicker } from "~/components/layout/font-size-picker"
import { SurfacePicker } from "~/components/layout/surface-picker"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session"
import {
addNotification,
dismiss,
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
import { Button } from "~/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"
import { Input } from "~/components/ui/input"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
type NavItem = {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
end?: boolean
}
const navItems: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/networking", icon: Network, label: "Networking" },
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
{ to: "/activity", icon: Activity, label: "Audit log" },
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/settings", icon: Settings, label: "Settings" },
// CREMA:NAV-ITEMS
]
type AppShellProps = {
children: React.ReactNode
brand?: Brand
user?: User
/**
* Optional theme name. When set, the shell wraps itself in
* `data-theme={theme}` so a route can opt into an alternate theme without
* the caller having to add an extra wrapping div.
*/
theme?: string
}
export function AppShell({
children,
brand: brandOverride,
user: userOverride,
theme,
}: AppShellProps) {
const defaultBrand = useBrand()
const defaultUser = useUser()
const profile = useProfile()
const session = useSession()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the stub user.
const user = userOverride ?? {
name: session?.name || defaultUser.name,
email: session?.email || defaultUser.email,
initials: profileInitials(session?.name || defaultUser.name),
}
// Protected shell: bounce to /login when there's no session.
useEffect(() => {
if (typeof window === "undefined") return
if (!session) {
const next = encodeURIComponent(
window.location.pathname + window.location.search,
)
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
// All hooks must run unconditionally — keep them above the session
// short-circuit so a sign-out doesn't reduce the hook count and trip
// React's "rendered fewer hooks than expected" check.
const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1"
})
useEffect(() => {
localStorage.setItem(SIDEBAR_KEY, expanded ? "1" : "0")
}, [expanded])
const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false)
useScriptsHotkey(() => setScriptsOpen(true))
if (!session) return null
const BrandIcon = brand.icon
return (
<div
data-theme={theme}
className="relative isolate flex min-h-svh"
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-2 focus:top-2 focus:z-50 focus:rounded-md focus:bg-primary focus:px-3 focus:py-2 focus:text-primary-foreground focus:shadow-lg"
>
Skip to main content
</a>
<aside
data-slot="sidebar"
data-expanded={expanded ? "true" : "false"}
className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex",
expanded ? "w-60" : "w-16",
].join(" ")}
>
<div
className={[
"flex h-14 items-center gap-2 border-b px-3",
expanded ? "justify-between" : "justify-center",
].join(" ")}
>
{expanded ? (
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
<span className="font-heading font-semibold tracking-tight">
{brand.name}
</span>
</div>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
)}
</div>
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
data-action={`nav-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors duration-fast ease-standard",
expanded ? "justify-start" : "justify-center",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
{expanded && <span className="truncate">{item.label}</span>}
</NavLink>
)
})}
</nav>
<div className="shrink-0 border-t p-2">
<Button
data-action="sidebar-toggle"
variant="ghost"
size={expanded ? "sm" : "icon-sm"}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
className={expanded ? "w-full justify-start" : "w-full"}
>
{expanded ? (
<>
<PanelLeftClose className="size-4" />
<span>Collapse</span>
</>
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
{/* Mobile-only menu trigger, floating top-left of main */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
data-action="mobile-nav-toggle"
aria-label="Open navigation"
className="fixed left-3 top-3 z-30 inline-flex size-9 items-center justify-center rounded-full border bg-card/70 text-muted-foreground shadow-sm backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground md:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<BrandIcon className="size-4" />
</div>
{brand.name}
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 p-2">
{navItems.map((item) => {
const Icon = item.icon
return (
<NavLink
key={item.label}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
data-action={`nav-mobile-${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg px-3 py-2 font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
<Icon className="size-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
)
})}
</nav>
</SheetContent>
</Sheet>
{/* Floating glass pill, top-right, replacing the appbar action group */}
<div
data-slot="floating-actions"
className="fixed right-3 top-3 z-30 flex items-center gap-0.5 rounded-full border bg-card/70 px-1.5 py-1 shadow-sm backdrop-blur-md"
>
<Button
data-action="appbar-scripts"
variant="ghost"
size="icon-sm"
aria-label="Run script (Cmd+Shift+P)"
title="Run script (Cmd+Shift+P)"
onClick={() => setScriptsOpen(true)}
>
<Play />
</Button>
<FontSizePicker />
<SurfacePicker />
<BackgroundPicker />
<ThemeToggle />
<NotificationsBell />
<DropdownMenu>
<DropdownMenuTrigger
data-action="appbar-avatar"
aria-label="Account menu"
className="ml-1 rounded-full outline-none ring-offset-2 ring-offset-background transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-7 cursor-pointer">
{profile.avatarUrl ? (
<AvatarImage src={profile.avatarUrl} alt={user.name} />
) : null}
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex flex-col gap-0.5">
<span className="font-medium">{user.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{user.email}
</span>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-profile"
onClick={() => navigate("/profile")}
>
<UserIcon /> Profile
</DropdownMenuItem>
<DropdownMenuItem
data-action="avatar-settings"
onClick={() => navigate("/settings")}
>
<Settings /> Settings
</DropdownMenuItem>
<DropdownMenuItem data-action="avatar-help">
<HelpCircle /> Help
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
data-action="avatar-signout"
variant="destructive"
onClick={() => {
signOut()
navigate("/login", { replace: true })
}}
>
<LogOut /> Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
id="main-content"
tabIndex={-1}
// First-child padding clears the fixed top-right floating actions
// pill so page headers can put refresh/new buttons in their normal
// top-right slot without sliding under the appbar avatar/controls.
className="flex flex-1 flex-col gap-6 p-6 focus:outline-none [&>*:first-child]:lg:pr-72"
>
{children}
</div>
</main>
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
<NotificationDispatcher />
</div>
)
}
function NotificationDispatcher() {
// Hidden bridge so the action bus can create real notifications:
// fill notify-title "Hello"
// fill notify-body "Body text"
// fill notify-kind "info" # info|success|warning|error
// fill notify-href "/library" # optional
// click notify-create
const titleRef = useRef<HTMLInputElement>(null)
const bodyRef = useRef<HTMLInputElement>(null)
const kindRef = useRef<HTMLInputElement>(null)
const hrefRef = useRef<HTMLInputElement>(null)
const submit = () => {
const title = titleRef.current?.value.trim() ?? ""
if (!title) return
const body = bodyRef.current?.value.trim() || undefined
const rawKind = (kindRef.current?.value || "info").trim().toLowerCase()
const kind = (
["info", "success", "warning", "error"].includes(rawKind)
? rawKind
: "info"
) as "info" | "success" | "warning" | "error"
const href = hrefRef.current?.value.trim() || undefined
addNotification({ title, body, kind, href })
if (titleRef.current) titleRef.current.value = ""
if (bodyRef.current) bodyRef.current.value = ""
if (kindRef.current) kindRef.current.value = ""
if (hrefRef.current) hrefRef.current.value = ""
}
return (
<div
aria-hidden
className="pointer-events-none fixed left-0 top-0 size-px overflow-hidden opacity-0"
>
<input
ref={titleRef}
data-action="notif-title"
placeholder="title"
aria-label="Notification title"
/>
<input
ref={bodyRef}
data-action="notif-body"
placeholder="body"
aria-label="Notification body"
/>
<input
ref={kindRef}
data-action="notif-kind"
placeholder="info|success|warning|error"
aria-label="Notification kind"
/>
<input
ref={hrefRef}
data-action="notif-href"
placeholder="/path (optional)"
aria-label="Notification href"
/>
<button
type="button"
data-action="notif-create"
aria-label="Create notification"
onClick={submit}
className="pointer-events-auto"
>
create
</button>
</div>
)
}
function NotificationsBell() {
const items = useNotifications()
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-notifications"
variant="ghost"
size="icon-sm"
aria-label="Notifications"
>
<span className="relative inline-flex">
<Bell />
{unread > 0 && (
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold text-primary-foreground">
{unread > 9 ? "9+" : unread}
</span>
)}
</span>
</Button>
}
/>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex items-center gap-1">
<Button
data-action="notif-mark-all-read"
variant="ghost"
size="sm"
onClick={() => markAllRead()}
disabled={unread === 0}
>
Mark all read
</Button>
<Button
data-action="notif-clear"
variant="ghost"
size="sm"
onClick={() => dismissAll()}
disabled={items.length === 0}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-80 overflow-y-auto">
{items.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications.
</li>
) : (
items.map((n) => (
<li
key={n.id}
className={
"group flex items-start gap-2 border-b px-3 py-2 text-sm transition-colors hover:bg-accent/40 " +
(!n.readAt ? "bg-primary/5" : "")
}
>
<span
className={
"mt-1 size-2 shrink-0 rounded-full " +
(n.kind === "success"
? "bg-emerald-500"
: n.kind === "warning"
? "bg-amber-500"
: n.kind === "error"
? "bg-rose-500"
: "bg-primary")
}
aria-hidden
/>
<button
type="button"
data-action={`notif-open-${n.id}`}
onClick={() => {
markRead(n.id)
if (n.href) navigate(n.href)
}}
className="flex flex-1 flex-col items-start text-left"
>
<span className="font-medium">{n.title}</span>
{n.body && (
<span className="text-xs text-muted-foreground">
{n.body}
</span>
)}
<span className="text-[10px] text-muted-foreground/70">
{new Date(n.createdAt).toLocaleString()}
</span>
</button>
<button
type="button"
data-action={`notif-dismiss-${n.id}`}
onClick={() => dismiss(n.id)}
aria-label="Dismiss"
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
>
<span aria-hidden>×</span>
</button>
</li>
))
)}
</ul>
</PopoverContent>
</Popover>
)
}