Wire operator Integrations page + capability-gating framework

Completes the arcadia-admin operator surface for the integration registry and
the capability/route-guard framework it depends on.

- Integration registry: route + Data-group nav entry + `platform.integrations`
  capability; the in-app client now delegates to the shared
  `@crema/integration-registry-client` lib (vite alias + tsconfig); the
  operator Integrations page (committed earlier) is now reachable.
- Capability gating: capabilities map + route-guard + jwt helpers + the
  apps/plan/entitlements routes and supporting tenants/session changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-06-09 23:09:24 +10:00
parent 06490865d3
commit 4b817b85ff
15 changed files with 1176 additions and 341 deletions

View File

@@ -39,6 +39,8 @@ import {
Plug,
MessageSquare,
Eye,
LayoutGrid,
CreditCard,
// CREMA:NAV-ICONS
} from "lucide-react"
@@ -67,6 +69,7 @@ import {
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session"
import { capabilityForPath, useCapabilities } from "~/lib/capabilities"
import {
addNotification,
dismiss,
@@ -96,6 +99,7 @@ import {
SheetTrigger,
} from "~/components/ui/sheet"
import { ScriptsDialog, useScriptsHotkey } from "~/components/scripts-dialog"
import { RouteGuard } from "~/components/route-guard"
type NavItem = {
to: string
@@ -134,6 +138,16 @@ const navGroups: NavGroup[] = [
{ to: "/sso", icon: ShieldCheck, label: "SSO" },
],
},
{
key: "billing",
label: "Billing",
icon: CreditCard,
items: [
{ to: "/apps", icon: LayoutGrid, label: "Apps" },
{ to: "/plan", icon: CreditCard, label: "Plan" },
{ to: "/entitlements", icon: Gauge, label: "Entitlements" },
],
},
{
key: "data",
label: "Data",
@@ -142,6 +156,7 @@ const navGroups: NavGroup[] = [
{ to: "/storage", icon: HardDrive, label: "Storage" },
{ to: "/buckets", icon: Boxes, label: "Buckets" },
{ to: "/secrets", icon: KeyRound, label: "Secrets" },
{ to: "/integrations", icon: Plug, label: "Integrations" },
],
},
{
@@ -189,15 +204,6 @@ 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 {
@@ -230,6 +236,7 @@ export function AppShell({
const defaultUser = useUser()
const profile = useProfile()
const session = useSession()
const caps = useCapabilities()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the stub user.
@@ -264,11 +271,51 @@ export function AppShell({
useScriptsHotkey(() => setScriptsOpen(true))
const location = useLocation()
// Filter the nav by what the active session can actually reach. A
// capability map exists for every protected route — items without one
// (or whose capability isn't held) are dropped here, so the sidebar
// doesn't advertise routes the user will only hit a 403 from.
const allowed = (item: NavItem): boolean => {
const cap = capabilityForPath(item.to)
if (!cap) return true // unknown routes default to visible
return caps.has(cap)
}
const visiblePinnedTop = useMemo(
() => pinnedTop.filter(allowed),
[caps],
)
const visiblePinnedBottom = useMemo(
() => pinnedBottom.filter(allowed),
[caps],
)
const visibleNavGroups: NavGroup[] = useMemo(
() =>
navGroups
.map((g) => ({ ...g, items: g.items.filter(allowed) }))
.filter((g) => g.items.length > 0),
[caps],
)
const visibleExtraItems = useMemo(
() => extraNavItems.filter(allowed),
[caps],
)
const visibleAllNavItems: NavItem[] = useMemo(
() => [
...visiblePinnedTop,
...visibleNavGroups.flatMap((g) => g.items),
...visibleExtraItems,
...visiblePinnedBottom,
],
[visiblePinnedTop, visibleNavGroups, visibleExtraItems, visiblePinnedBottom],
)
const activeGroupKey = useMemo(
() =>
navGroups.find((g) => g.items.some((it) => location.pathname.startsWith(it.to)))
?.key ?? null,
[location.pathname],
visibleNavGroups.find((g) =>
g.items.some((it) => location.pathname.startsWith(it.to)),
)?.key ?? null,
[location.pathname, visibleNavGroups],
)
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>(() =>
@@ -339,11 +386,11 @@ export function AppShell({
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{expanded ? (
<>
{pinnedTop.map((item) => (
{visiblePinnedTop.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
{navGroups.map((group) => {
{visibleNavGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
@@ -375,16 +422,16 @@ export function AppShell({
)
})}
{extraNavItems.length > 0 ? (
{visibleExtraItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{extraNavItems.map((item) => (
{visibleExtraItems.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) => (
{visiblePinnedBottom.map((item) => (
<NavRow key={item.label} item={item} expanded />
))}
</div>
@@ -392,7 +439,7 @@ export function AppShell({
) : (
// Icon-only rail: flat list, no group headers.
<>
{allNavItems.map((item) => (
{visibleAllNavItems.map((item) => (
<NavRow key={item.label} item={item} expanded={false} />
))}
</>
@@ -443,7 +490,7 @@ export function AppShell({
</SheetTitle>
</SheetHeader>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
{pinnedTop.map((item) => (
{visiblePinnedTop.map((item) => (
<NavRow
key={item.label}
item={item}
@@ -453,7 +500,7 @@ export function AppShell({
/>
))}
{navGroups.map((group) => {
{visibleNavGroups.map((group) => {
const isOpen = !!openGroups[group.key]
const GroupIcon = group.icon
return (
@@ -492,9 +539,9 @@ export function AppShell({
)
})}
{extraNavItems.length > 0 ? (
{visibleExtraItems.length > 0 ? (
<div className="mt-1.5 flex flex-col gap-0.5">
{extraNavItems.map((item) => (
{visibleExtraItems.map((item) => (
<NavRow
key={item.label}
item={item}
@@ -507,7 +554,7 @@ export function AppShell({
) : null}
<div className="mt-auto flex flex-col gap-0.5 pt-2">
{pinnedBottom.map((item) => (
{visiblePinnedBottom.map((item) => (
<NavRow
key={item.label}
item={item}
@@ -601,12 +648,16 @@ export function AppShell({
<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"
className="flex flex-1 flex-col focus:outline-none"
>
{children}
{/* Centered content column. Caps line lengths and frames pages
on wide displays so the canvas reads as composed instead of
one floating card in a sea of black. The floating actions
pill is fixed to the viewport edge and lives outside this
column, so it stays clear regardless of cap width. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72">
<RouteGuard>{children}</RouteGuard>
</div>
</div>
</main>
@@ -645,7 +696,11 @@ function NavRow({
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",
"relative flex items-center gap-3 rounded-lg py-2 text-sm font-medium transition-colors duration-fast ease-standard",
// 2px left accent rail on active. Absolute-positioned so the rail
// anchors to the rail's edge regardless of per-item left padding,
// and a fixed 14px height keeps it from filling tall rows.
"before:absolute before:left-0 before:top-1/2 before:h-3.5 before:w-[2px] before:-translate-y-1/2 before:rounded-r-full before:bg-primary before:opacity-0 before:transition-opacity before:duration-fast",
expanded
? inGroup
? // Indent the label by chevron(12) + gap(8) = 20px so it
@@ -654,7 +709,7 @@ function NavRow({
: "justify-start px-3"
: "justify-center px-3",
isActive
? "bg-primary/10 text-primary"
? "bg-primary/[0.08] text-primary before:opacity-100"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
].join(" ")
}