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

@@ -6,6 +6,14 @@
import { useEffect, useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
import { decodeJwt, type AvailableTenantClaim } from "~/lib/jwt"
export type AvailableTenant = {
id: string
slug?: string
name?: string
roles: string[]
}
export type Session = {
userId: string
@@ -14,6 +22,11 @@ export type Session = {
token: string
// Issued at, ms since epoch.
issuedAt: number
// Active membership context — derived from the JWT.
tenantId?: string
tenantSlug?: string
roles: string[]
availableTenants: AvailableTenant[]
}
const STORAGE_KEY = "crema.session"
@@ -41,6 +54,18 @@ function readFromStorage(): Session | null {
token: parsed.token,
issuedAt:
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
tenantId: typeof parsed.tenantId === "string" ? parsed.tenantId : undefined,
tenantSlug:
typeof parsed.tenantSlug === "string" ? parsed.tenantSlug : undefined,
roles: Array.isArray(parsed.roles)
? parsed.roles.filter((r): r is string => typeof r === "string")
: [],
availableTenants: Array.isArray(parsed.availableTenants)
? (parsed.availableTenants.filter(
(t): t is AvailableTenant =>
!!t && typeof (t as AvailableTenant).id === "string",
) as AvailableTenant[])
: [],
}
} catch {
return null
@@ -72,12 +97,31 @@ export function persistFromArcadiaLogin(
[user?.first_name, user?.last_name].filter(Boolean).join(" ") ||
user?.email ||
"Signed-in user"
const claims = decodeJwt(tokens.access_token) ?? {}
const availableTenants: AvailableTenant[] = Array.isArray(
claims.available_tenants,
)
? (claims.available_tenants as AvailableTenantClaim[])
.filter((t) => t && typeof t.id === "string")
.map((t) => ({
id: t.id as string,
slug: t.slug,
name: t.name,
roles: Array.isArray(t.roles) ? t.roles : [],
}))
: []
const session: Session = {
userId: user?.id ?? `arcadia-${Date.now().toString(36)}`,
name,
email: user?.email ?? "",
token: tokens.access_token,
issuedAt: Date.now(),
tenantId:
typeof claims.tenant_id === "string" ? claims.tenant_id : undefined,
tenantSlug:
typeof claims.tenant_slug === "string" ? claims.tenant_slug : undefined,
roles: Array.isArray(claims.roles) ? claims.roles : [],
availableTenants,
}
if (typeof window !== "undefined") {
sessionStorage.setItem("arcadia_access_token", tokens.access_token)