feat(app): M5.2 — customer-area route shells + role-gated nav
ci / shared (push) Successful in 4s
ci / test (push) Successful in 29s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped

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:
2026-05-19 14:47:15 +00:00
parent 2961f36cca
commit fe139332ee
18 changed files with 524 additions and 32 deletions
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { canSee, hasAnyOrgRole, hasOrgRole, hasProduct } from "./session";
import type { SessionWithExtras } from "./session";
function s(roles: SessionWithExtras["org_roles"], products: string[] = []): SessionWithExtras {
return {
user: { name: "Test", email: "t@x.test" },
expires: "2099-01-01T00:00:00Z",
org_roles: roles,
products,
};
}
describe("hasOrgRole", () => {
test("null session has no roles", () => {
expect(hasOrgRole(null, "IT_ADMIN")).toBe(false);
});
test("matches single role", () => {
expect(hasOrgRole(s(["CXO"]), "CXO")).toBe(true);
expect(hasOrgRole(s(["CXO"]), "IT_ADMIN")).toBe(false);
});
});
describe("hasAnyOrgRole", () => {
test("any match wins", () => {
expect(hasAnyOrgRole(s(["LEGAL"]), ["IT_ADMIN", "LEGAL"])).toBe(true);
expect(hasAnyOrgRole(s(["USER"]), ["IT_ADMIN", "CXO"])).toBe(false);
});
test("empty roles", () => {
expect(hasAnyOrgRole(s(undefined), ["IT_ADMIN"])).toBe(false);
});
});
describe("hasProduct", () => {
test("checks products array", () => {
expect(hasProduct(s(["USER"], ["certifai"]), "certifai")).toBe(true);
expect(hasProduct(s(["USER"], ["certifai"]), "compliance")).toBe(false);
expect(hasProduct(null, "certifai")).toBe(false);
});
});
describe("canSee", () => {
test("IT_ADMIN sees settings, USER does not", () => {
expect(canSee(s(["IT_ADMIN"]), "settings")).toBe(true);
expect(canSee(s(["USER"]), "settings")).toBe(false);
});
test("CXO can see billing", () => {
expect(canSee(s(["CXO"]), "billing")).toBe(true);
});
test("LEGAL can see audit but not settings", () => {
expect(canSee(s(["LEGAL"]), "audit")).toBe(true);
expect(canSee(s(["LEGAL"]), "settings")).toBe(false);
});
test("FINANCE sees billing but not settings", () => {
expect(canSee(s(["FINANCE"]), "billing")).toBe(true);
expect(canSee(s(["FINANCE"]), "settings")).toBe(false);
});
test("dashboard visible to everyone with any role", () => {
expect(canSee(s(["USER"]), "dashboard")).toBe(true);
expect(canSee(s(["LEGAL"]), "dashboard")).toBe(true);
});
test("null session sees nothing", () => {
expect(canSee(null, "dashboard")).toBe(false);
});
});
+70
View File
@@ -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]);
}