Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051ae0626 | |||
| cd4b6720d8 | |||
| ac22ccef9b |
@@ -1,19 +0,0 @@
|
|||||||
# portal — local dev environment.
|
|
||||||
# Copy to .env.local (gitignored).
|
|
||||||
|
|
||||||
# Tenant Registry — see platform/tenant-registry. Run `make dev` there.
|
|
||||||
TENANT_REGISTRY_URL=http://localhost:8090
|
|
||||||
|
|
||||||
# Keycloak (dev stack from platform/orca-platform/dev).
|
|
||||||
KEYCLOAK_ISSUER=http://localhost:8080/realms/breakpilot-dev
|
|
||||||
KEYCLOAK_CLIENT_ID=dev-portal
|
|
||||||
# Public PKCE client — secret is structurally required by Auth.js but unused
|
|
||||||
# at the OAuth code-exchange step. Any non-empty placeholder works in dev.
|
|
||||||
KEYCLOAK_CLIENT_SECRET=unused-public-client
|
|
||||||
|
|
||||||
# Auth.js v5 — required for JWT signing.
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
AUTH_SECRET=dev-secret-change-me-do-not-ship-replace-with-32-byte-random
|
|
||||||
AUTH_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# In prod we'd set AUTH_TRUST_HOST=true behind orca-proxy; dev is loopback so leave unset.
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
if: hashFiles('package.json') != ''
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -72,17 +72,11 @@ jobs:
|
|||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
# `pnpm test` already includes --coverage via the package.json script.
|
- run: pnpm test --coverage
|
||||||
- run: pnpm test
|
|
||||||
- name: coverage gate
|
- name: coverage gate
|
||||||
run: |
|
run: |
|
||||||
node -e "const c=require('./coverage/coverage-summary.json').total.lines.pct; if (c<70) { console.error('coverage', c, '< 70%'); process.exit(1) }"
|
node -e "const c=require('./coverage/coverage-summary.json').total.lines.pct; if (c<70) { console.error('coverage', c, '< 70%'); process.exit(1) }"
|
||||||
- name: build
|
- run: pnpm build
|
||||||
env:
|
|
||||||
# Required at build-time by Auth.js. Replaced by Infisical-sourced
|
|
||||||
# secret in stage/prod via Orca env injection (M5.1+).
|
|
||||||
AUTH_SECRET: ci-build-dummy-${{ github.sha }}
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
needs: test
|
needs: test
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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)
|
- 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
|
- feat(app): Next.js 16 + Auth.js v5 skeleton with host→slug middleware, tenant context layout, OIDC sign-in flow
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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,6 +1,4 @@
|
|||||||
import { auth, signIn, signOut } from "@/auth";
|
import { auth, signIn, signOut } from "@/auth";
|
||||||
import { ShellEmpty } from "@/components/ShellEmpty";
|
|
||||||
import type { SessionWithExtras } from "@/lib/session";
|
|
||||||
|
|
||||||
export default async function Dashboard({
|
export default async function Dashboard({
|
||||||
params,
|
params,
|
||||||
@@ -8,7 +6,7 @@ export default async function Dashboard({
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const session = (await auth()) as SessionWithExtras | null;
|
const session = await auth();
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
async function login() {
|
async function login() {
|
||||||
@@ -16,25 +14,12 @@ export default async function Dashboard({
|
|||||||
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
|
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<section style={{ maxWidth: 480 }}>
|
<div>
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 12 }}>Sign in to {slug}</h1>
|
<h1>Sign in to {slug}</h1>
|
||||||
<form action={login}>
|
<form action={login}>
|
||||||
<button
|
<button type="submit">Sign in with Keycloak</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>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,60 +28,17 @@ export default async function Dashboard({
|
|||||||
await signOut({ redirectTo: `/${slug}/dashboard` });
|
await signOut({ redirectTo: `/${slug}/dashboard` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const products = session.products ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div>
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
<p>
|
||||||
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
|
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. This is the{" "}
|
||||||
as <code>{session.org_roles?.join(", ") ?? "(no roles)"}</code>.
|
<code>{slug}</code> dashboard. Real product tiles, settings, billing — land
|
||||||
|
in M5.2 / M10.1.
|
||||||
</p>
|
</p>
|
||||||
|
<form action={logout} style={{ marginTop: 24 }}>
|
||||||
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 12 }}>Your products</h2>
|
<button type="submit">Sign out</button>
|
||||||
{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>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
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";
|
import { fetchTenantBySlug } from "@/lib/tenant-registry";
|
||||||
|
|
||||||
export default async function TenantLayout({
|
export default async function TenantLayout({
|
||||||
@@ -16,19 +13,8 @@ export default async function TenantLayout({
|
|||||||
const tenant = await fetchTenantBySlug(slug);
|
const tenant = await fetchTenantBySlug(slug);
|
||||||
if (!tenant) notFound();
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
<div>
|
||||||
{session ? <Nav slug={slug} session={session} /> : null}
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "12px 24px",
|
||||||
@@ -46,6 +32,5 @@ export default async function TenantLayout({
|
|||||||
</header>
|
</header>
|
||||||
<main style={{ padding: 24 }}>{children}</main>
|
<main style={{ padding: 24 }}>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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 (
|
|
||||||
<section style={{ padding: 32 }}>
|
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Backstage</h1>
|
|
||||||
<p>Sign in with a BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP account.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<section style={{ padding: 32 }}>
|
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Backstage</h1>
|
|
||||||
<p>
|
|
||||||
Signed in as <code>{session.user?.email}</code>.
|
|
||||||
</p>
|
|
||||||
<p style={{ marginTop: 24, color: "#666" }}>
|
|
||||||
Tenants list, leads, demo console, impersonation — all land in M13.2 / M14.x.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 (
|
|
||||||
<nav style={{ width: 220, padding: 16, borderRight: "1px solid #eaeaea", background: "white" }}>
|
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
|
||||||
{visible.map((l) => (
|
|
||||||
<li key={l.href} style={{ margin: "8px 0" }}>
|
|
||||||
<Link href={l.href} style={{ color: "#0070f3", textDecoration: "none" }}>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// 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 (
|
|
||||||
<section style={{ maxWidth: 720 }}>
|
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>{title}</h1>
|
|
||||||
<p style={{ color: "#444", marginBottom: 24 }}>{description}</p>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: 16,
|
|
||||||
border: "1px dashed #ddd",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#fafafa",
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
This surface is a route shell. Real implementation lands in{" "}
|
|
||||||
<code>{milestone}</code>. See{" "}
|
|
||||||
<a
|
|
||||||
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PLATFORM_ARCHITECTURE.md"
|
|
||||||
style={{ color: "#0070f3" }}
|
|
||||||
>
|
|
||||||
PLATFORM_ARCHITECTURE.md §5a
|
|
||||||
</a>{" "}
|
|
||||||
for the spec.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotAuthorized() {
|
|
||||||
return (
|
|
||||||
<section style={{ maxWidth: 720 }}>
|
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>403 — Not authorized</h1>
|
|
||||||
<p style={{ color: "#444" }}>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// 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]);
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,7 @@ describe("fetchTenantBySlug", () => {
|
|||||||
globalThis.fetch = fetchSpy;
|
globalThis.fetch = fetchSpy;
|
||||||
await fetchTenantBySlug("acme");
|
await fetchTenantBySlug("acme");
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
"http://localhost:8090/v1/tenants/by-slug/acme",
|
"http://localhost:8080/v1/tenants/by-slug/acme",
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type Tenant = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function baseUrl(): string {
|
function baseUrl(): string {
|
||||||
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
|
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8080";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
|
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
|
||||||
|
|||||||
Reference in New Issue
Block a user