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
+33 -18
View File
@@ -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 (
<div>
<header
style={{
padding: "12px 24px",
borderBottom: "1px solid #eaeaea",
background: "white",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<strong>{tenant.name}</strong>
<span style={{ fontSize: 12, color: "#666" }}>
{tenant.plan} · {tenant.status}
</span>
</header>
<main style={{ padding: 24 }}>{children}</main>
<div style={{ display: "flex", minHeight: "100vh" }}>
{session ? <Nav slug={slug} session={session} /> : null}
<div style={{ flex: 1 }}>
<header
style={{
padding: "12px 24px",
borderBottom: "1px solid #eaeaea",
background: "white",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<strong>{tenant.name}</strong>
<span style={{ fontSize: 12, color: "#666" }}>
{tenant.plan} · {tenant.status}
</span>
</header>
<main style={{ padding: 24 }}>{children}</main>
</div>
</div>
);
}