// Capability gating — the contract between roles, nav, and routes. // // A capability is a *thing the user can do in this UI*. The set held by // the current session is computed from their active membership's roles // + the slug of the active tenant (platform-admin gets the platform.* // fleet by default). Sidebar nav filters by it; per-route guards 403 // when the user deep-links to one they don't hold. // // The server is the real authority — these checks are UI-shaping, not // security. Don't ever trust the client capability check on its own. export type Capability = // tenant.* — held by tenant_admin on the active membership. | "tenant.home" | "tenant.users" | "tenant.invitations" | "tenant.roles" | "tenant.memberships" | "tenant.apps" | "tenant.plan" | "tenant.entitlements" | "tenant.storage" | "tenant.buckets" | "tenant.activity" | "tenant.settings" | "tenant.profile" // platform.* — held by platform_admin on platform-admin. | "platform.tenants" | "platform.organizations" | "platform.networking" | "platform.monitoring" | "platform.status_page" | "platform.scheduled_tasks" | "platform.secrets" | "platform.webhooks" | "platform.announcements" | "platform.sso" | "platform.library" | "platform.search" | "platform.ai" | "platform.integrations" // external-API registry (keys/budgets) on the gateway // Special — always-on; not gated. | "always.assistant" | "always.profile" /** Roles arcadia issues that this UI knows about. */ export type Role = | "platform_admin" | "tenant_admin" | "member" | (string & {}) // accept unknown roles forward-compat const TENANT_ADMIN_CAPS: Capability[] = [ "tenant.home", "tenant.users", "tenant.invitations", "tenant.roles", "tenant.memberships", "tenant.apps", "tenant.plan", "tenant.entitlements", "tenant.storage", "tenant.buckets", "tenant.activity", "tenant.settings", "tenant.profile", ] const PLATFORM_ADMIN_CAPS: Capability[] = [ // platform_admin also gets every tenant.* — they're an admin of the // platform-admin tenant, so they manage *its* users, storage, etc. ...TENANT_ADMIN_CAPS, "platform.tenants", "platform.organizations", "platform.networking", "platform.monitoring", "platform.status_page", "platform.scheduled_tasks", "platform.secrets", "platform.webhooks", "platform.announcements", "platform.sso", "platform.library", "platform.search", "platform.ai", "platform.integrations", ] const ALWAYS_CAPS: Capability[] = ["always.assistant", "always.profile"] export function capabilitiesForRoles(roles: readonly string[] | undefined): Set { const caps = new Set(ALWAYS_CAPS) const has = (r: string) => (roles ?? []).includes(r) if (has("platform_admin")) PLATFORM_ADMIN_CAPS.forEach((c) => caps.add(c)) if (has("tenant_admin") || has("admin")) TENANT_ADMIN_CAPS.forEach((c) => caps.add(c)) // "member" / other roles get only the always-on set. return caps } /** Pure helper — handy in tests + route loaders. */ export function holds(caps: Set, cap: Capability): boolean { return caps.has(cap) } // ----------------------------- Route map ---------------------------- // // Every protected route declares which capability it needs. Sidebar nav // and the per-route guard both read this map, so the contract lives in // one place. export const ROUTE_CAPABILITY: Record = { "/": "tenant.home", "/users": "tenant.users", "/memberships": "tenant.memberships", "/storage": "tenant.storage", "/buckets": "tenant.buckets", "/activity": "tenant.activity", "/settings": "tenant.settings", "/apps": "tenant.apps", "/plan": "tenant.plan", "/entitlements": "tenant.entitlements", "/tenants": "platform.tenants", "/organizations": "platform.organizations", "/networking": "platform.networking", "/monitoring": "platform.monitoring", "/status-page": "platform.status_page", "/scheduled-tasks": "platform.scheduled_tasks", "/secrets": "platform.secrets", "/webhooks": "platform.webhooks", "/announcements": "platform.announcements", "/sso": "platform.sso", "/library": "platform.library", "/search": "platform.search", "/ai": "platform.ai", "/integrations": "platform.integrations", "/assistant": "always.assistant", "/profile": "always.profile", } // ----------------------------- Hooks -------------------------------- import { useMemo } from "react" import { useSession } from "~/lib/session" /** The active session's capability set. Empty when not signed in. */ export function useCapabilities(): Set { const session = useSession() return useMemo(() => capabilitiesForRoles(session?.roles), [session?.roles]) } export function useHasCapability(cap: Capability): boolean { return useCapabilities().has(cap) } export function capabilityForPath(pathname: string): Capability | null { // Exact match first. if (ROUTE_CAPABILITY[pathname]) return ROUTE_CAPABILITY[pathname] // Then prefix match — "/users/123" inherits "/users"'s capability. // Walk known keys longest-first so "/scheduled-tasks/x" picks the // right one over "/s". const keys = Object.keys(ROUTE_CAPABILITY).sort((a, b) => b.length - a.length) for (const k of keys) { if (k !== "/" && pathname.startsWith(k + "/")) return ROUTE_CAPABILITY[k] } return null }