diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 919919f..ed6b1e9 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -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 { 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>(() => @@ -339,11 +386,11 @@ export function AppShell({