From 06490865d3d70d0429df712db27ffb70299f9242 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 9 Jun 2026 21:14:13 +1000 Subject: [PATCH 1/2] Add operator Integrations page (integration registry console) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator surface for the integration registry: manage platform/pooled external-API credentials across every scope and inspect cross-tenant usage (metadata only — secrets are write-only). Talks to arcadia-llm-gateway's /api/v1/integrations* endpoints via a gateway-pointed ArcadiaClient. - gateway.ts: second ArcadiaClient at VITE_LLM_GATEWAY_URL, reusing the arcadia-app JWT (the gateway validates it via the shared Guardian secret; CORS already allows *.sky-ai.com + localhost — no proxy). - lib/arcadia/integrations.ts: operator API client (any-scope create, scope filter, cross-tenant usage). Pure functions over an injected client — extraction-ready to share with arcadia-console. - routes/integrations.tsx: scope filter + per-card scope badge, create platform/pooled credentials, credentials/usage, Test (surfaces the expiry/budget gate), enable toggle, delete. The route/nav/capability wiring (routes.ts, app-shell, capabilities.ts) lands with the in-flight capability framework, not here. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/lib/arcadia/integrations.ts | 205 +++++++++++ app/lib/gateway.ts | 38 ++ app/routes/integrations.tsx | 632 ++++++++++++++++++++++++++++++++ 3 files changed, 875 insertions(+) create mode 100644 app/lib/arcadia/integrations.ts create mode 100644 app/lib/gateway.ts create mode 100644 app/routes/integrations.tsx diff --git a/app/lib/arcadia/integrations.ts b/app/lib/arcadia/integrations.ts new file mode 100644 index 0000000..7a77ac7 --- /dev/null +++ b/app/lib/arcadia/integrations.ts @@ -0,0 +1,205 @@ +// Integration-registry API client (operator surface). +// +// Talks to arcadia-llm-gateway's `/api/v1/integrations*` endpoints — the +// platform operator manages pooled/platform credentials at ANY scope and +// inspects cross-tenant usage *metadata* (never secrets; reads return +// `has_secret`, never a value). +// +// The tenant mirror (`/api/v1/me/integrations*`, scoped to the caller) lives +// in arcadia-console. Pure functions over an injected gateway `ArcadiaClient` +// (see `~/lib/gateway`); no app coupling, so this lifts into a shared lib once +// both apps exist. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type Scope = "platform" | "tenant" | "app" | "user" | "agent" +export type AuthKind = "bearer_static" | "basic" | "api_key_header" | "oauth2" +export type CredentialSource = "byo" | "pooled" +export type CredentialStatus = "active" | "expired" | "revoked" + +export interface CostModel { + unit?: "call" | "search" | "1k_tokens" + price_usd?: string | number + currency?: string +} + +export interface Constraints { + rate_per_min?: number + monthly_budget_usd?: string | number + monthly_call_cap?: number +} + +export interface Credential { + id: string + integration_id: string + secret_name: string + auth_kind: AuthKind + meta: Record + /** Presence, not value — the secret is write-only. */ + has_secret: boolean + expires_at: string | null + last_rotated_at: string | null + source: CredentialSource + status: CredentialStatus +} + +export interface Integration { + id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + display_name: string | null + description: string | null + docs_url: string | null + base_url: string | null + enabled: boolean + cost_model: CostModel + constraints: Constraints + created_by: string | null + credentials: Credential[] +} + +export interface UsageEntry { + integration_id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + units: number + cost_usd: string + calls: number +} + +export interface IntegrationInput { + scope?: Scope + scope_id?: string | null + provider: string + capability?: string | null + display_name?: string | null + description?: string | null + docs_url?: string | null + base_url?: string | null + enabled?: boolean + cost_model?: CostModel + constraints?: Constraints +} + +export interface CredentialInput { + secret_name: string + auth_kind: AuthKind + /** Plaintext, write-only — sent on create/rotate, never returned. */ + secret?: string + source?: CredentialSource + expires_at?: string | null + meta?: Record +} + +export interface TestVerdict { + status: string + auth_kind?: AuthKind + policy?: { + within_budget: boolean + within_rate: boolean + remaining_budget_usd: string | null + } +} + +export interface ScopeFilter { + scope?: Scope + scope_id?: string +} + +const BASE = "/api/v1/integrations" + +export async function listIntegrations( + c: ArcadiaClient, + filter: ScopeFilter = {}, +): Promise { + const res = await c.GET<{ integrations: Integration[] }>(BASE, { + params: { scope: filter.scope, scope_id: filter.scope_id }, + }) + return res.integrations +} + +export async function createIntegration( + c: ArcadiaClient, + input: IntegrationInput, +): Promise { + const res = await c.POST<{ integration: Integration }>(BASE, { body: input }) + return res.integration +} + +export async function updateIntegration( + c: ArcadiaClient, + id: string, + input: Partial, +): Promise { + const res = await c.PATCH<{ integration: Integration }>(`${BASE}/${id}`, { body: input }) + return res.integration +} + +export async function deleteIntegration(c: ArcadiaClient, id: string): Promise { + await c.DELETE(`${BASE}/${id}`) +} + +export async function addCredential( + c: ArcadiaClient, + integrationId: string, + input: CredentialInput, +): Promise { + const res = await c.POST<{ credential: Credential }>(`${BASE}/${integrationId}/credentials`, { + body: input, + }) + return res.credential +} + +export async function updateCredential( + c: ArcadiaClient, + credentialId: string, + input: Partial, +): Promise { + const res = await c.PATCH<{ credential: Credential }>(`/api/v1/credentials/${credentialId}`, { + body: input, + }) + return res.credential +} + +export async function deleteCredential(c: ArcadiaClient, credentialId: string): Promise { + await c.DELETE(`/api/v1/credentials/${credentialId}`) +} + +/** + * Run the registry's policy gate against an integration's credential. 200 with + * a verdict; 4xx (409 expired / 429 over-budget / 404 no credential) is thrown + * by `ArcadiaClient` as an `ArcadiaError` — inspect `err.status`. + */ +export async function testIntegration(c: ArcadiaClient, id: string): Promise { + return c.POST(`${BASE}/${id}/test`) +} + +export async function usageSummary( + c: ArcadiaClient, + filter: ScopeFilter = {}, +): Promise { + const res = await c.GET<{ usage: UsageEntry[] }>(`${BASE}/usage`, { + params: { scope: filter.scope, scope_id: filter.scope_id }, + }) + return res.usage +} + +// ── display helpers ──────────────────────────────────────────────────── + +export function formatUsd(value: string | number | null | undefined): string { + if (value === null || value === undefined) return "—" + const n = typeof value === "string" ? Number(value) : value + if (Number.isNaN(n)) return "—" + return `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}` +} + +export function credentialHealth(cred: Credential): "ok" | "expired" | "revoked" | "missing" { + if (cred.status === "revoked") return "revoked" + if (!cred.has_secret) return "missing" + if (cred.expires_at && new Date(cred.expires_at).getTime() < Date.now()) return "expired" + return "ok" +} diff --git a/app/lib/gateway.ts b/app/lib/gateway.ts new file mode 100644 index 0000000..471d934 --- /dev/null +++ b/app/lib/gateway.ts @@ -0,0 +1,38 @@ +// Arcadia LLM-gateway client. +// +// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so +// it needs its own ArcadiaClient pointed at a different base URL. Everything +// else is identical to the arcadia-app client: the same access token (the +// gateway validates arcadia-app JWTs via the shared Guardian secret) and the +// same 401 cleanup. The gateway's CORS already allows localhost + any +// *.sky-ai.com origin, so the browser calls it directly. + +import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client" + +const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015" + +const ACCESS_TOKEN_KEY = "arcadia_access_token" +const REFRESH_TOKEN_KEY = "arcadia_refresh_token" + +let client: ArcadiaClient | null = null + +export function gatewayClient(): ArcadiaClient { + if (!client) { + client = createArcadiaClient({ + baseUrl: GATEWAY_URL, + getToken: () => + typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY), + onUnauthorized: () => { + if (typeof window !== "undefined") { + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + } + }, + }) + } + return client +} + +export function useGatewayClient(): ArcadiaClient { + return gatewayClient() +} diff --git a/app/routes/integrations.tsx b/app/routes/integrations.tsx new file mode 100644 index 0000000..d1bc374 --- /dev/null +++ b/app/routes/integrations.tsx @@ -0,0 +1,632 @@ +// Integrations (operator) — platform/pooled external-API arrangements across +// every scope, backed by the integration registry on arcadia-llm-gateway +// (`/api/v1/integrations*`). The operator manages pooled credentials and +// inspects cross-tenant usage metadata; secrets are write-only. + +import { useCallback, useEffect, useMemo, useState } from "react" +import { + AlertTriangle, + CheckCircle2, + FlaskConical, + KeyRound, + Pencil, + Plug, + Plus, + Trash2, +} from "lucide-react" +import { ArcadiaError } from "@crema/arcadia-client" + +import { AppShell } from "~/components/layout/app-shell" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import { Switch } from "~/components/ui/switch" +import { useGatewayClient } from "~/lib/gateway" +import { + addCredential, + createIntegration, + credentialHealth, + deleteIntegration, + formatUsd, + listIntegrations, + testIntegration, + updateIntegration, + usageSummary, + type AuthKind, + type Integration, + type Scope, + type UsageEntry, +} from "~/lib/arcadia/integrations" + +const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"] +const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"] +const SCOPE_FILTERS: Array = ["all", ...SCOPES] + +type Form = { + scope: Scope + scope_id: string + provider: string + capability: string + display_name: string + unit: string + price_usd: string + monthly_budget_usd: string + secret_name: string + auth_kind: AuthKind + secret: string + pooled: boolean +} + +const emptyForm: Form = { + scope: "platform", + scope_id: "", + provider: "", + capability: "", + display_name: "", + unit: "call", + price_usd: "", + monthly_budget_usd: "", + secret_name: "", + auth_kind: "bearer_static", + secret: "", + pooled: true, +} + +export default function IntegrationsRoute() { + const gw = useGatewayClient() + const [items, setItems] = useState([]) + const [usage, setUsage] = useState([]) + const [scopeFilter, setScopeFilter] = useState("all") + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editing, setEditing] = useState(null) + const [tests, setTests] = useState>({}) + + const refresh = useCallback(async () => { + setError(null) + const filter = scopeFilter === "all" ? {} : { scope: scopeFilter } + try { + const [list, use] = await Promise.all([ + listIntegrations(gw, filter), + usageSummary(gw, filter).catch(() => [] as UsageEntry[]), + ]) + setItems(list) + setUsage(use) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load integrations.") + } finally { + setLoading(false) + } + }, [gw, scopeFilter]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const usageById = useMemo( + () => new Map(usage.map((u) => [u.integration_id, u] as const)), + [usage], + ) + + const runTest = useCallback( + async (it: Integration) => { + setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } })) + try { + const verdict = await testIntegration(gw, it.id) + const remaining = verdict.policy?.remaining_budget_usd + setTests((t) => ({ + ...t, + [it.id]: { + ok: true, + message: + verdict.status === "ok" + ? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}` + : verdict.status, + }, + })) + } catch (e) { + const msg = + e instanceof ArcadiaError + ? e.status === 409 + ? "Credential expired — rotate it" + : e.status === 429 + ? "Over budget / rate limit" + : e.status === 404 + ? "No credential to test" + : e.message + : "Test failed" + setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } })) + } + }, + [gw], + ) + + const toggleEnabled = useCallback( + async (it: Integration, enabled: boolean) => { + setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x))) + try { + await updateIntegration(gw, it.id, { enabled }) + } catch { + setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x))) + } + }, + [gw], + ) + + const remove = useCallback( + async (it: Integration) => { + if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return + await deleteIntegration(gw, it.id) + await refresh() + }, + [gw, refresh], + ) + + return ( + +
+
+
+ +
+
+

Integrations

+

+ Platform & pooled external-API credentials across every scope. + Keys are stored encrypted and never shown; usage is metadata only. +

+
+
+
+ + +
+
+ + {error ? ( + + + Couldn’t load integrations + {error} + + + ) : loading ? ( +

Loading…

+ ) : items.length === 0 ? ( + + + No integrations in this scope + + Register a platform/pooled arrangement — a shared key the platform + meters and bills to tenants who opt in. + + + + + + + ) : ( +
+ {items.map((it) => { + const u = usageById.get(it.id) + const test = tests[it.id] + return ( + + +
+
+ + {it.display_name || it.provider} + {it.scope} + {it.scope_id ? ( + + {it.scope_id} + + ) : null} + {it.capability ? ( + {it.capability} + ) : null} + + + {it.provider} + {it.cost_model?.price_usd + ? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}` + : ""} + {it.constraints?.monthly_budget_usd + ? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo` + : ""} + +
+
+ + toggleEnabled(it, v)} + /> +
+
+
+ +
+ {it.credentials.length === 0 ? ( +

No credential set.

+ ) : ( + it.credentials.map((cred) => { + const health = credentialHealth(cred) + return ( +
+ + {cred.secret_name} + {cred.source} + + {cred.expires_at ? ( + + expires {new Date(cred.expires_at).toLocaleDateString()} + + ) : null} +
+ ) + }) + )} +
+ +

+ {u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"} +

+ + {test ? ( +

+ {test.message} +

+ ) : null} + +
+ + + +
+
+
+ ) + })} +
+ )} + + {editing ? ( + setEditing(null)} + onSaved={async () => { + setEditing(null) + await refresh() + }} + /> + ) : null} +
+ ) +} + +function HealthBadge({ health }: { health: ReturnType }) { + if (health === "ok") + return ( + + healthy + + ) + const label = health === "missing" ? "no secret" : health + return ( + + {label} + + ) +} + +function IntegrationDialog({ + mode, + initial, + onClose, + onSaved, +}: { + mode: "new" | "edit" + initial: Integration | null + onClose: () => void + onSaved: () => void | Promise +}) { + const gw = useGatewayClient() + const [form, setForm] = useState
(() => + initial + ? { + ...emptyForm, + scope: initial.scope, + scope_id: initial.scope_id ?? "", + provider: initial.provider, + capability: initial.capability ?? "", + display_name: initial.display_name ?? "", + unit: initial.cost_model?.unit ?? "call", + price_usd: initial.cost_model?.price_usd?.toString() ?? "", + monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "", + } + : emptyForm, + ) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState(null) + const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })) + + const needsScopeId = form.scope !== "platform" + + const submit = async () => { + setSaving(true) + setErr(null) + try { + const cost_model = form.price_usd + ? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" } + : undefined + const constraints = form.monthly_budget_usd + ? { monthly_budget_usd: form.monthly_budget_usd } + : undefined + + if (mode === "edit" && initial) { + await updateIntegration(gw, initial.id, { + provider: form.provider.trim(), + capability: form.capability.trim() || undefined, + display_name: form.display_name.trim() || undefined, + cost_model, + constraints, + }) + } else { + const created = await createIntegration(gw, { + scope: form.scope, + scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined, + provider: form.provider.trim(), + capability: form.capability.trim() || undefined, + display_name: form.display_name.trim() || undefined, + cost_model, + constraints, + }) + if (form.secret_name.trim() && form.secret.trim()) { + await addCredential(gw, created.id, { + secret_name: form.secret_name.trim(), + auth_kind: form.auth_kind, + secret: form.secret, + source: form.pooled ? "pooled" : "byo", + }) + } + } + await onSaved() + } catch (e) { + setErr(e instanceof Error ? e.message : "Save failed.") + } finally { + setSaving(false) + } + } + + return ( + (!o ? onClose() : undefined)}> + + + {mode === "new" ? "Add integration" : "Edit integration"} + + Register an external-API arrangement. Platform scope = a pooled key + the platform meters and bills. + + + +
+ {mode === "new" ? ( +
+ + + + + set({ scope_id: e.target.value })} + disabled={!needsScopeId} + placeholder={needsScopeId ? "acme" : "—"} + /> + +
+ ) : null} + + + set({ provider: e.target.value })} + placeholder="tavily" + /> + + + set({ capability: e.target.value })} + placeholder="web_search" + /> + + + set({ display_name: e.target.value })} + /> + +
+ + set({ price_usd: e.target.value })} + placeholder="0.01" + /> + + + + +
+ + set({ monthly_budget_usd: e.target.value })} + placeholder="500" + /> + + + {mode === "new" ? ( +
+

Credential (optional)

+ + set({ secret_name: e.target.value })} + placeholder="tavily_default" + /> + +
+ + + + +
+ set({ pooled: v })} + /> + +
+
+
+ + set({ secret: e.target.value })} + placeholder="sk-…" + /> + +
+ ) : null} + + {err ?

{err}

: null} +
+ + + + + +
+
+ ) +} + +function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ) +} -- 2.48.1 From 4b817b85ff69d3d6681b4a07500df4379436cb73 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 9 Jun 2026 23:09:24 +1000 Subject: [PATCH 2/2] 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) --- app/components/layout/app-shell.tsx | 115 ++++++--- app/components/route-guard.tsx | 50 ++++ app/lib/arcadia/integrations.ts | 223 +++-------------- app/lib/arcadia/tenants.ts | 20 ++ app/lib/capabilities.ts | 168 +++++++++++++ app/lib/jwt.ts | 49 ++++ app/lib/session.ts | 44 ++++ app/routes.ts | 4 + app/routes/announcements.tsx | 361 +++++++++++++++++++++------- app/routes/apps.tsx | 44 ++++ app/routes/entitlements.tsx | 42 ++++ app/routes/plan.tsx | 40 +++ app/routes/tenants.tsx | 244 ++++++++++++++++++- tsconfig.json | 2 + vite.config.ts | 111 ++++++--- 15 files changed, 1176 insertions(+), 341 deletions(-) create mode 100644 app/components/route-guard.tsx create mode 100644 app/lib/capabilities.ts create mode 100644 app/lib/jwt.ts create mode 100644 app/routes/apps.tsx create mode 100644 app/routes/entitlements.tsx create mode 100644 app/routes/plan.tsx 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({