Files
arcadia-admin/app/components/layout/app-shell.tsx
jules a299900021 organizations: admin surface for tenant orgs
New /organizations route under Tenancy. Lists every org in the current
tenant (via GET /api/v1/admin/organizations), with per-row Manage
members and Settings dialogs.

- Members dialog: invite by email, add restricted sub-user, change role,
  transfer ownership, remove member (owner removal honors the org's
  on_owner_removal policy server-side)
- Settings dialog: edit name, status (active/frozen/pending_deletion),
  and on_owner_removal policy
- app/lib/arcadia/organizations.ts: typed client for the new endpoints
- Nav entry added under Tenancy group

Tenant admins bypass per-org membership checks via the backend's
OrganizationContext plug, so the per-org REST endpoints work for any
org in the tenant without an explicit /admin/* surface.
2026-05-15 19:50:48 +10:00

855 lines
28 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, useMemo, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar"
const NAV_GROUPS_KEY = "crema.shell.nav-groups"
import { NavLink, useLocation, 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,
Building,
ShieldCheck,
Megaphone,
AlertOctagon,
SearchCode,
ChevronDown,
Database,
Plug,
MessageSquare,
Eye,
// 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
}
type NavGroup = {
key: string
label: string
icon: React.ComponentType<{ className?: string }>
items: NavItem[]
}
// Pinned items render flat at the top of the rail, above any groups.
const pinnedTop: NavItem[] = [
{ to: "/", icon: LayoutDashboard, label: "Overview", end: true },
]
// Pinned items render flat at the bottom of the rail, below all groups.
const pinnedBottom: NavItem[] = [
{ to: "/settings", icon: Settings, label: "Settings" },
]
const navGroups: NavGroup[] = [
{
key: "tenancy",
label: "Tenancy",
icon: Building2,
items: [
{ to: "/tenants", icon: Building2, label: "Tenants" },
{ to: "/memberships", icon: UserCheck, label: "Memberships" },
{ to: "/organizations", icon: Building, label: "Organizations" },
{ to: "/users", icon: UsersIcon, label: "Users" },
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
{
key: "data",
label: "Data",
icon: Database,
items: [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
],
},
{
key: "integrations",
label: "Integrations",
icon: Plug,
items: [
{ to: "/webhooks", icon: WebhookIcon, label: "Webhooks" },
{ to: "/scheduled-tasks", icon: CalendarClock, label: "Scheduled" },
{ to: "/networking", icon: Network, label: "Networking" },
],
},
{
key: "comms",
label: "Communications",
icon: MessageSquare,
items: [
{ to: "/announcements", icon: Megaphone, label: "Announcements" },
{ to: "/status-page", icon: AlertOctagon, label: "Status page" },
],
},
{
key: "observability",
label: "Observability",
icon: Eye,
items: [
{ to: "/monitoring", icon: Gauge, label: "Monitoring" },
{ to: "/activity", icon: Activity, label: "Audit log" },
],
},
{
key: "ai",
label: "AI & Search",
icon: Sparkles,
items: [
{ to: "/ai", icon: Bot, label: "AI" },
{ to: "/search", icon: SearchCode, label: "Search" },
],
},
]
// Items appended by `crema add <lib>` land here. Rendered ungrouped at
// the bottom of the groups, above the pinned footer.
const extraNavItems: NavItem[] = [
// CREMA:NAV-ITEMS
]
// Flat list — used by the icon-only collapsed rail, where group headers
// don't render and items appear as a single column of icons.
const allNavItems: NavItem[] = [
...pinnedTop,
...navGroups.flatMap((g) => g.items),
...extraNavItems,
...pinnedBottom,
]
function readNavGroupState(): Record<string, boolean> {
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(NAV_GROUPS_KEY)
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {}
} catch {
return {}
}
}
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))
const location = useLocation()
const activeGroupKey = useMemo(
() =>
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
?.key ?? null,
[location.pathname],
)
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
readNavGroupState(),
)
// Auto-open the group that owns the current route on first mount or
// navigation, but never auto-close — the user's explicit toggles win.
useEffect(() => {
if (!activeGroupKey) return
setOpenGroups((prev) =>
prev[activeGroupKey] ? prev : { ...prev, [activeGroupKey]: true },
)
}, [activeGroupKey])
useEffect(() => {
if (typeof window === "undefined") return
localStorage.setItem(NAV_GROUPS_KEY, JSON.stringify(openGroups))
}, [openGroups])
const toggleGroup = (key: string) =>
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
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-0.5 overflow-y-auto p-2">
{expanded ? (
<>
{pinnedTop.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
{navGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
<button
type="button"
data-action={`nav-group-${group.key}`}
onClick={() => toggleGroup(group.key)}
aria-expanded={isOpen}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
>
<GroupIcon className="size-3.5 shrink-0" />
<span className="flex-1 truncate">{group.label}</span>
<ChevronDown
className={[
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
isOpen ? "" : "-rotate-90",
].join(" ")}
/>
</button>
{isOpen ? (
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavRow key={item.label} item={item} expanded inGroup />
))}
</div>
) : null}
</div>
)
})}
{extraNavItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{extraNavItems.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
</div>
) : null}
<div className="mt-auto flex flex-col gap-0.5 pt-2">
{pinnedBottom.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
</div>
</>
) : (
// Icon-only rail: flat list, no group headers.
<>
{allNavItems.map((item) => (
<NavRow key={item.label} item={item} expanded={false} />
))}
</>
)}
</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="flex h-svh w-72 flex-col p-0"
>
<SheetHeader className="shrink-0 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 min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{pinnedTop.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
{navGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
<div key={group.key} className="mt-3 flex flex-col gap-0.5">
<button
type="button"
data-action={`nav-mobile-group-${group.key}`}
onClick={() => toggleGroup(group.key)}
aria-expanded={isOpen}
className="flex items-center gap-2 rounded-md px-3 py-1.5 text-left text-caption font-semibold uppercase tracking-wider text-muted-foreground/70 transition-colors duration-fast ease-standard hover:text-foreground"
>
<GroupIcon className="size-3.5 shrink-0" />
<span className="flex-1 truncate">{group.label}</span>
<ChevronDown
className={[
"size-3.5 shrink-0 transition-transform duration-fast ease-standard",
isOpen ? "" : "-rotate-90",
].join(" ")}
/>
</button>
{isOpen ? (
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
inGroup
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
) : null}
</div>
)
})}
{extraNavItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{extraNavItems.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
) : null}
<div className="mt-auto flex flex-col gap-0.5 pt-2">
{pinnedBottom.map((item) => (
<NavRow
key={item.label}
item={item}
expanded
mobile
onNavigate={() => setMobileOpen(false)}
/>
))}
</div>
</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
key={profile.avatarUrl}
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 NavRow({
item,
expanded,
mobile = false,
inGroup = false,
onNavigate,
}: {
item: NavItem
expanded: boolean
mobile?: boolean
/** True when rendered inside a collapsible group — hides the per-item
* icon and indents the label so it aligns under the group header. */
inGroup?: boolean
onNavigate?: () => void
}) {
const Icon = item.icon
const prefix = mobile ? "nav-mobile-" : "nav-"
// Icons are hidden inside groups in the expanded rail. The collapsed
// icon-only rail (expanded=false) always shows icons regardless.
const showIcon = !inGroup || !expanded
return (
<NavLink
to={item.to}
end={item.end}
title={expanded ? undefined : item.label}
onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
expanded
? inGroup
? // Indent the label by chevron(12) + gap(8) = 20px so it
// visually aligns under the group header label.
"justify-start pl-[1.625rem] pr-3"
: "justify-start px-3"
: "justify-center px-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}
>
{showIcon ? <Icon className="size-5 shrink-0" /> : null}
{expanded ? <span className="truncate">{item.label}</span> : null}
</NavLink>
)
}
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>
)
}