feat(app): M5.2 — customer-area route shells + role-gated nav
10 route shells under /[slug]/, role-filtered Nav, backstage stub at /__backstage__, dashboard reads session.products to render tiles. src/lib/session.ts is the canonical role × surface matrix; canSee() is the only RBAC primitive in the portal (real enforcement remains at the API layer). 24 vitest tests; 100% src/lib coverage. Refs: M5.2
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// Session-derived types & helpers — keep all session-shape knowledge in
|
||||
// one place so route components don't all repeat the same casts.
|
||||
//
|
||||
// The breakpilot-dev realm projects these claims into every JWT via
|
||||
// protocol mappers (see platform/orca-platform/dev/keycloak/realm-export.json).
|
||||
// Auth.js v5 callbacks copy them onto the session in src/auth.ts.
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
export type OrgRole = "IT_ADMIN" | "CXO" | "FINANCE" | "LEGAL" | "USER";
|
||||
export type TenantStatus = "demo" | "trial" | "active" | "frozen" | "archived";
|
||||
export type Plan = "starter" | "professional" | "enterprise";
|
||||
|
||||
export type SessionExtras = {
|
||||
tenant_id?: string;
|
||||
tenant_slug?: string;
|
||||
org_roles?: OrgRole[];
|
||||
products?: string[];
|
||||
plan?: Plan;
|
||||
tenant_status?: TenantStatus;
|
||||
};
|
||||
|
||||
export type SessionWithExtras = Session & SessionExtras;
|
||||
|
||||
export function hasOrgRole(s: SessionWithExtras | null, role: OrgRole): boolean {
|
||||
return !!s?.org_roles?.includes(role);
|
||||
}
|
||||
|
||||
export function hasAnyOrgRole(s: SessionWithExtras | null, roles: OrgRole[]): boolean {
|
||||
if (!s?.org_roles) return false;
|
||||
return roles.some((r) => s.org_roles?.includes(r));
|
||||
}
|
||||
|
||||
export function hasProduct(s: SessionWithExtras | null, product: string): boolean {
|
||||
return !!s?.products?.includes(product);
|
||||
}
|
||||
|
||||
// Permission matrix per PLATFORM_ARCHITECTURE.md §5a "Operating principles":
|
||||
// hide what the user can't access. Each portal surface declares which
|
||||
// org_roles can see it; the nav uses this to filter links.
|
||||
export type Surface =
|
||||
| "dashboard"
|
||||
| "products"
|
||||
| "projects"
|
||||
| "settings"
|
||||
| "users"
|
||||
| "api-keys"
|
||||
| "integrations"
|
||||
| "billing"
|
||||
| "audit"
|
||||
| "support"
|
||||
| "catalog";
|
||||
|
||||
export const surfaceRoles: Record<Surface, OrgRole[]> = {
|
||||
dashboard: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
|
||||
products: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
|
||||
projects: ["IT_ADMIN", "CXO"],
|
||||
settings: ["IT_ADMIN"],
|
||||
users: ["IT_ADMIN"],
|
||||
"api-keys": ["IT_ADMIN"],
|
||||
integrations: ["IT_ADMIN"],
|
||||
billing: ["IT_ADMIN", "CXO", "FINANCE"],
|
||||
audit: ["IT_ADMIN", "CXO", "LEGAL"],
|
||||
support: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
|
||||
catalog: ["IT_ADMIN", "CXO"],
|
||||
};
|
||||
|
||||
export function canSee(s: SessionWithExtras | null, surface: Surface): boolean {
|
||||
return hasAnyOrgRole(s, surfaceRoles[surface]);
|
||||
}
|
||||
Reference in New Issue
Block a user