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,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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Audit log"
|
||||
description="Every state-changing action across portal + products."
|
||||
milestone="M10.2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Billing"
|
||||
description="Plan, seats, invoices. Polar Checkout opens here."
|
||||
milestone="M8.3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Catalog"
|
||||
description="Products you can add to your subscription."
|
||||
milestone="M11.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h1>Sign in to {slug}</h1>
|
||||
<section style={{ maxWidth: 480 }}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 12 }}>Sign in to {slug}</h1>
|
||||
<form action={login}>
|
||||
<button type="submit">Sign in with Keycloak</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
background: "#0070f3",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Sign in with Keycloak
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,17 +43,60 @@ export default async function Dashboard({
|
||||
await signOut({ redirectTo: `/${slug}/dashboard` });
|
||||
}
|
||||
|
||||
const products = session.products ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>
|
||||
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. This is the{" "}
|
||||
<code>{slug}</code> dashboard. Real product tiles, settings, billing — land
|
||||
in M5.2 / M10.1.
|
||||
<section>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
|
||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
||||
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
|
||||
as <code>{session.org_roles?.join(", ") ?? "(no roles)"}</code>.
|
||||
</p>
|
||||
<form action={logout} style={{ marginTop: 24 }}>
|
||||
<button type="submit">Sign out</button>
|
||||
|
||||
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 12 }}>Your products</h2>
|
||||
{products.length === 0 ? (
|
||||
<ShellEmpty
|
||||
title="No products yet"
|
||||
description="Browse the catalog and request access to a product, or start a 14-day trial."
|
||||
milestone="M11.1"
|
||||
/>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
|
||||
{products.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
style={{
|
||||
padding: 16,
|
||||
border: "1px solid #eaeaea",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<strong style={{ textTransform: "capitalize" }}>{p}</strong>
|
||||
<p style={{ color: "#666", fontSize: 13, marginTop: 4 }}>
|
||||
Tile content lands in <code>M10.1</code>.
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form action={logout} style={{ marginTop: 32 }}>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "white",
|
||||
color: "#0070f3",
|
||||
border: "1px solid #0070f3",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-18
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Products"
|
||||
description="Per-product tiles — open into the embedded web component."
|
||||
milestone="M10.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Projects"
|
||||
description="Sub-tenancy: GCP-Project-style scoping per product."
|
||||
milestone="M10.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="API keys"
|
||||
description="Per-tenant API keys for headless product access."
|
||||
milestone="M15.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Integrations"
|
||||
description="Webhooks, outbound integrations, external IdP config."
|
||||
milestone="M15.2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Settings"
|
||||
description="Tenant identity, SSO, organization defaults."
|
||||
milestone="M10.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Users"
|
||||
description="Invite IT_ADMIN, CXO, FINANCE, LEGAL, USER. Role assignment."
|
||||
milestone="M10.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Support"
|
||||
description="Submit a ticket — Frappe HD customer portal embedded."
|
||||
milestone="M9.1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user