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:
@@ -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(" ")
|
||||
}
|
||||
|
||||
50
app/components/route-guard.tsx
Normal file
50
app/components/route-guard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// Per-route capability guard. Wrap the page body — if the active
|
||||
// session doesn't hold the route's capability, render a 403 instead of
|
||||
// the page. Server-side authz is still the real gate; this is UX so a
|
||||
// deep link doesn't 500 inside a route loader that assumes access.
|
||||
|
||||
import { useLocation } from "react-router"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
|
||||
import {
|
||||
capabilityForPath,
|
||||
useCapabilities,
|
||||
type Capability,
|
||||
} from "~/lib/capabilities"
|
||||
import { Card, CardContent } from "~/components/ui/card"
|
||||
|
||||
type RouteGuardProps = {
|
||||
children: React.ReactNode
|
||||
/** Override the capability derived from the current path. Useful for
|
||||
* nested routes where you want to check a specific cap. */
|
||||
capability?: Capability
|
||||
}
|
||||
|
||||
export function RouteGuard({ children, capability }: RouteGuardProps) {
|
||||
const caps = useCapabilities()
|
||||
const location = useLocation()
|
||||
const required = capability ?? capabilityForPath(location.pathname)
|
||||
// No mapping = route is intentionally unguarded (e.g. login flows
|
||||
// never reach AppShell anyway).
|
||||
if (!required) return <>{children}</>
|
||||
if (caps.has(required)) return <>{children}</>
|
||||
return <Forbidden capability={required} />
|
||||
}
|
||||
|
||||
function Forbidden({ capability }: { capability: Capability }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<ShieldAlert className="size-10 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">You can't access this page</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This view requires the <code className="font-mono text-xs">{capability}</code>{" "}
|
||||
capability on your active tenant. If you think you should have it,
|
||||
switch tenants from the avatar menu or ask an admin.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user