From 60209428b561ba6c73cdc37b9ce80e7650e766c3 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 13:54:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(app):=20M5.2=20=E2=80=94=20customer-ar?= =?UTF-8?q?ea=20route=20shells=20+=20role-gated=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the M5.2 surface set per PLATFORM_ARCHITECTURE.md §5a. Every route is a navigable skeleton with a per-route empty-state pointing at the milestone that ships the real content; the Nav component filters links by session.org_roles so an IT_ADMIN sees settings + api-keys, a CXO sees billing, a USER sees only dashboard + products + support, etc. New surfaces (10): /[slug]/products M10.1 /[slug]/projects M10.1 /[slug]/catalog M11.1 /[slug]/settings M10.1 /[slug]/settings/users M10.1 /[slug]/settings/api-keys M15.1 /[slug]/settings/integrations M15.2 /[slug]/billing M8.3 /[slug]/audit M10.2 /[slug]/support M9.1 Dashboard upgraded: reads session.products, renders one tile per entitled product (real tile content lands in M10.1). Empty-state when the user has no entitlements yet — links into the catalog flow. Backstage stub at /__backstage__ — middleware already rewrites backstage./* to this prefix; real RBAC against BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP lands in M13.2. Layout enforces tenant-slug match: a session with tenant_slug=A trying to view /B/... gets redirected to /A/dashboard. Prevents JWT-replay across tenants (defence in depth; the real guard is at the API layer, which M4.3 adds in tenant-registry). src/lib/session.ts is the single source of truth for the role matrix + canSee(surface) helper. 13 vitest cases, 100% coverage of src/lib. Refs: M5.2 --- CHANGELOG.md | 1 + src/app/[slug]/audit/page.tsx | 16 ++++ src/app/[slug]/billing/page.tsx | 16 ++++ src/app/[slug]/catalog/page.tsx | 16 ++++ src/app/[slug]/dashboard/page.tsx | 86 ++++++++++++++++--- src/app/[slug]/layout.tsx | 51 +++++++---- src/app/[slug]/products/page.tsx | 16 ++++ src/app/[slug]/projects/page.tsx | 16 ++++ src/app/[slug]/settings/api-keys/page.tsx | 16 ++++ src/app/[slug]/settings/integrations/page.tsx | 16 ++++ src/app/[slug]/settings/page.tsx | 16 ++++ src/app/[slug]/settings/users/page.tsx | 16 ++++ src/app/[slug]/support/page.tsx | 16 ++++ src/app/__backstage__/page.tsx | 30 +++++++ src/components/Nav.tsx | 40 +++++++++ src/components/ShellEmpty.tsx | 53 ++++++++++++ src/lib/session.test.ts | 65 ++++++++++++++ src/lib/session.ts | 70 +++++++++++++++ 18 files changed, 524 insertions(+), 32 deletions(-) create mode 100644 src/app/[slug]/audit/page.tsx create mode 100644 src/app/[slug]/billing/page.tsx create mode 100644 src/app/[slug]/catalog/page.tsx create mode 100644 src/app/[slug]/products/page.tsx create mode 100644 src/app/[slug]/projects/page.tsx create mode 100644 src/app/[slug]/settings/api-keys/page.tsx create mode 100644 src/app/[slug]/settings/integrations/page.tsx create mode 100644 src/app/[slug]/settings/page.tsx create mode 100644 src/app/[slug]/settings/users/page.tsx create mode 100644 src/app/[slug]/support/page.tsx create mode 100644 src/app/__backstage__/page.tsx create mode 100644 src/components/Nav.tsx create mode 100644 src/components/ShellEmpty.tsx create mode 100644 src/lib/session.test.ts create mode 100644 src/lib/session.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b0b6c..8e2a640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl ## [Unreleased] ### Added +- feat(app): M5.2 — customer-area route shells (settings, billing, audit, support, catalog, products, projects, settings/{users,api-keys,integrations}); shared Nav component reads session.org_roles and shows only what each role can see; backstage stub at /__backstage__; dashboard renders product tiles from session.products - chore(deps): bump next + eslint-config-next to 16.2.6 to clear trivy CVEs (CVE-2025-29927 critical + 7 highs in next 15.0.3) - feat(app): Next.js 16 + Auth.js v5 skeleton with host→slug middleware, tenant context layout, OIDC sign-in flow - diff --git a/src/app/[slug]/audit/page.tsx b/src/app/[slug]/audit/page.tsx new file mode 100644 index 0000000..34b451b --- /dev/null +++ b/src/app/[slug]/audit/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "audit")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/billing/page.tsx b/src/app/[slug]/billing/page.tsx new file mode 100644 index 0000000..1e408e1 --- /dev/null +++ b/src/app/[slug]/billing/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "billing")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/catalog/page.tsx b/src/app/[slug]/catalog/page.tsx new file mode 100644 index 0000000..91f7651 --- /dev/null +++ b/src/app/[slug]/catalog/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "catalog")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/dashboard/page.tsx b/src/app/[slug]/dashboard/page.tsx index 85a3167..f413124 100644 --- a/src/app/[slug]/dashboard/page.tsx +++ b/src/app/[slug]/dashboard/page.tsx @@ -1,4 +1,6 @@ import { auth, signIn, signOut } from "@/auth"; +import { ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; export default async function Dashboard({ params, @@ -6,7 +8,7 @@ export default async function Dashboard({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const session = await auth(); + const session = (await auth()) as SessionWithExtras | null; if (!session) { async function login() { @@ -14,12 +16,25 @@ export default async function Dashboard({ await signIn("keycloak", { redirectTo: `/${slug}/dashboard` }); } return ( -
-

Sign in to {slug}

+
+

Sign in to {slug}

- +
-
+ ); } @@ -28,17 +43,60 @@ export default async function Dashboard({ await signOut({ redirectTo: `/${slug}/dashboard` }); } + const products = session.products ?? []; + return ( -
-

Dashboard

-

- Welcome, {session.user?.name ?? session.user?.email ?? "user"}. This is the{" "} - {slug} dashboard. Real product tiles, settings, billing — land - in M5.2 / M10.1. +

+

Dashboard

+

+ Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in + as {session.org_roles?.join(", ") ?? "(no roles)"}.

-
- + +

Your products

+ {products.length === 0 ? ( + + ) : ( +
    + {products.map((p) => ( +
  • + {p} +

    + Tile content lands in M10.1. +

    +
  • + ))} +
+ )} + + + -
+ ); } diff --git a/src/app/[slug]/layout.tsx b/src/app/[slug]/layout.tsx index 11785b1..c75fb97 100644 --- a/src/app/[slug]/layout.tsx +++ b/src/app/[slug]/layout.tsx @@ -1,5 +1,8 @@ -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import type { ReactNode } from "react"; +import { auth } from "@/auth"; +import { Nav } from "@/components/Nav"; +import type { SessionWithExtras } from "@/lib/session"; import { fetchTenantBySlug } from "@/lib/tenant-registry"; export default async function TenantLayout({ @@ -13,24 +16,36 @@ export default async function TenantLayout({ const tenant = await fetchTenantBySlug(slug); if (!tenant) notFound(); + const session = (await auth()) as SessionWithExtras | null; + + // Tenant mismatch guard — a JWT scoped to tenant A must not be allowed + // to view tenant B. If the slug in the path doesn't match the session + // tenant_slug, redirect back to whatever this user CAN see. + if (session && session.tenant_slug && session.tenant_slug !== slug) { + redirect(`/${session.tenant_slug}/dashboard`); + } + return ( -
-
- {tenant.name} - - {tenant.plan} · {tenant.status} - -
-
{children}
+
+ {session ?
); } diff --git a/src/app/[slug]/products/page.tsx b/src/app/[slug]/products/page.tsx new file mode 100644 index 0000000..b8f8977 --- /dev/null +++ b/src/app/[slug]/products/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "dashboard")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/projects/page.tsx b/src/app/[slug]/projects/page.tsx new file mode 100644 index 0000000..5b1d4c9 --- /dev/null +++ b/src/app/[slug]/projects/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "projects")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/settings/api-keys/page.tsx b/src/app/[slug]/settings/api-keys/page.tsx new file mode 100644 index 0000000..b7e2beb --- /dev/null +++ b/src/app/[slug]/settings/api-keys/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "api-keys")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/settings/integrations/page.tsx b/src/app/[slug]/settings/integrations/page.tsx new file mode 100644 index 0000000..0db3dc3 --- /dev/null +++ b/src/app/[slug]/settings/integrations/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "integrations")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/settings/page.tsx b/src/app/[slug]/settings/page.tsx new file mode 100644 index 0000000..fea0707 --- /dev/null +++ b/src/app/[slug]/settings/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "settings")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/settings/users/page.tsx b/src/app/[slug]/settings/users/page.tsx new file mode 100644 index 0000000..432ca48 --- /dev/null +++ b/src/app/[slug]/settings/users/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "users")) return ; + return ( + + ); +} diff --git a/src/app/[slug]/support/page.tsx b/src/app/[slug]/support/page.tsx new file mode 100644 index 0000000..67b9f6a --- /dev/null +++ b/src/app/[slug]/support/page.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/auth"; +import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; +import type { SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +export default async function Page() { + const session = (await auth()) as SessionWithExtras | null; + if (!canSee(session, "support")) return ; + return ( + + ); +} diff --git a/src/app/__backstage__/page.tsx b/src/app/__backstage__/page.tsx new file mode 100644 index 0000000..a0e7152 --- /dev/null +++ b/src/app/__backstage__/page.tsx @@ -0,0 +1,30 @@ +import { auth } from "@/auth"; +import type { SessionWithExtras } from "@/lib/session"; + +// Backstage — platform-staff-only surface. The middleware rewrites +// http://backstage.localhost:3000/* → /__backstage__/* so this is +// reachable only via that hostname. Real RBAC (BREAKPILOT_ADMIN / +// SUPPORT_ENGINEER / SALES_REP) lands in M13.2. + +export default async function Backstage() { + const session = (await auth()) as SessionWithExtras | null; + if (!session) { + return ( +
+

Backstage

+

Sign in with a BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP account.

+
+ ); + } + return ( +
+

Backstage

+

+ Signed in as {session.user?.email}. +

+

+ Tenants list, leads, demo console, impersonation — all land in M13.2 / M14.x. +

+
+ ); +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx new file mode 100644 index 0000000..1563183 --- /dev/null +++ b/src/components/Nav.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import type { Surface, SessionWithExtras } from "@/lib/session"; +import { canSee } from "@/lib/session"; + +type NavLink = { + href: string; + label: string; + surface: Surface; +}; + +export function Nav({ slug, session }: { slug: string; session: SessionWithExtras }) { + const links: NavLink[] = [ + { href: `/${slug}/dashboard`, label: "Dashboard", surface: "dashboard" }, + { href: `/${slug}/products`, label: "Products", surface: "products" }, + { href: `/${slug}/catalog`, label: "Catalog", surface: "catalog" }, + { href: `/${slug}/projects`, label: "Projects", surface: "projects" }, + { href: `/${slug}/settings`, label: "Settings", surface: "settings" }, + { href: `/${slug}/settings/users`, label: "Users", surface: "users" }, + { href: `/${slug}/settings/api-keys`, label: "API keys", surface: "api-keys" }, + { href: `/${slug}/settings/integrations`, label: "Integrations", surface: "integrations" }, + { href: `/${slug}/billing`, label: "Billing", surface: "billing" }, + { href: `/${slug}/audit`, label: "Audit log", surface: "audit" }, + { href: `/${slug}/support`, label: "Support", surface: "support" }, + ]; + const visible = links.filter((l) => canSee(session, l.surface)); + + return ( + + ); +} diff --git a/src/components/ShellEmpty.tsx b/src/components/ShellEmpty.tsx new file mode 100644 index 0000000..c0db025 --- /dev/null +++ b/src/components/ShellEmpty.tsx @@ -0,0 +1,53 @@ +// Reusable empty-state for a customer-area route shell. Every M5.2 route +// renders one of these; real content lands in M10.1 / M11.x / M12.x / +// M14.x / etc. + +export function ShellEmpty({ + title, + description, + milestone, +}: { + title: string; + description: string; + milestone: string; +}) { + return ( +
+

{title}

+

{description}

+
+ This surface is a route shell. Real implementation lands in{" "} + {milestone}. See{" "} + + PLATFORM_ARCHITECTURE.md §5a + {" "} + for the spec. +
+
+ ); +} + +export function NotAuthorized() { + return ( +
+

403 — Not authorized

+

+ This surface requires a role your account doesn't have. If you think + that's a mistake, ask an IT_ADMIN on your tenant to invite you with + the right role. +

+
+ ); +} diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts new file mode 100644 index 0000000..cebe850 --- /dev/null +++ b/src/lib/session.test.ts @@ -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); + }); +}); diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..1fd7bd7 --- /dev/null +++ b/src/lib/session.ts @@ -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 = { + 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]); +} -- 2.52.0 From 2a12f2f7e4a1415db22f8243f6642e5772f15486 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Tue, 19 May 2026 13:59:04 +0200 Subject: [PATCH 2/2] chore: trigger ci -- 2.52.0