-
No products yet.
-
- Browse the catalog →
-
+
+
+
+
Products
+
+ {t.entitled.length} entitled · click a product to open its launch screen
+
- ) : (
-
- {active.map((e) => (
-
- ))}
-
- )}
-
- );
-}
+
-function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) {
- return (
-
-
-
{catalog?.name ?? ent.product}
- {ent.expires_at && (
-
- trial · {formatRelative(ent.expires_at)}
+
+ {t.products.map((p) => {
+ const isLive = p.status === "live";
+ const entitled = t.entitled.includes(p.id);
+ const trialing = t.trialing.includes(p.id);
+ const openCount = t.findings.filter((f) => f.product === p.id && f.status === "open").length;
+ const evidence = Math.floor(t.metrics.evidence / Math.max(1, t.entitled.length));
+
+ if (!isLive) {
+ return (
+
+
+
+
+ {p.name}
+ {p.slug}
+
+
+
{p.blurb}
+
+ Coming soon
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {p.name}
+ {p.slug}
+
+
+
+
{p.blurb}
+
+ {p.frameworks.map((f) => (
+ {f}
+ ))}
+
+
+
+
+
{evidence}
+
Evidence
+
+
+
+ {entitled ? (
+ Entitled
+ ) : trialing ? (
+ Trialing
+ ) : null}
+
+
Plan
+
+
+
+ );
+ })}
+
+
+
+ {["all", "compliance-scanner", "certifai"].map((o) => (
+
+ {o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
+
+ ))}
- )}
-
- {catalog?.description && (
-
- {catalog.description}
-
- )}
-
- Web component renders here once {ent.product}-dashboard is
- registered (M6.3 / M7.2).
-
-
+ }
+ pad={false}
+ >
+
+
+
+ Sev
+ ID
+ Title
+ Product
+ Control
+ Age
+ Status
+
+
+
+ {findings.slice(0, 12).map((f) => (
+
+
+ {f.id}
+
+ {f.title}
+
+ {f.product}
+ {f.control}
+ {f.ageDays}d
+
+
+
+ {f.status === "resolved" ? "Resolved" : "Open"}
+
+
+
+ ))}
+
+
+
+
);
}
diff --git a/src/app/[slug]/settings/integrations/page.tsx b/src/app/[slug]/settings/integrations/page.tsx
index b13ead3..2528001 100644
--- a/src/app/[slug]/settings/integrations/page.tsx
+++ b/src/app/[slug]/settings/integrations/page.tsx
@@ -1,17 +1,76 @@
-import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
-import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
+import { getPortalSession } from "@/lib/get-session";
+import { loadTenantForShell } from "@/lib/portal-data";
+import { Panel } from "@/components/portal/Panel";
+import { NotAllowed } from "@/components/portal/NotAllowed";
+
+export default async function SSOPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const session = await getPortalSession();
+ if (!canSee(session, "integrations")) return
;
+ const t = await loadTenantForShell(slug);
+ if (!t) return null;
+
+ const groupMappings: [string, string][] = [
+ [`${t.id}/groups/it-admins`, "IT_ADMIN"],
+ [`${t.id}/groups/cxo`, "CXO"],
+ [`${t.id}/groups/finance`, "FINANCE"],
+ [`${t.id}/groups/legal`, "LEGAL"],
+ [`${t.id}/groups/all-users`, "USER"],
+ ];
-export default async function Page() {
- const session = (await auth()) as SessionWithExtras | null;
- if (!canSee(session, "integrations")) return
;
return (
-
+
+
+
+
SSO
+
Single sign-on via Keycloak (OIDC) — managed by Breakpilot Platform
+
+
+
+
+ connection healthy
+
+
+
+
+
+
+
+
+ Provider Keycloak (breakpilot-dev realm)
+ Protocol OpenID Connect / authorization_code + PKCE
+ Issuer {process.env.KEYCLOAK_ISSUER ?? "http://localhost:8080/realms/breakpilot-dev"}
+ Client ID {process.env.KEYCLOAK_CLIENT_ID ?? "dev-portal"}
+ Redirect URI {`https://${t.id}.breakpilot.eu/api/auth/callback/keycloak`}
+ Scopes openid profile email tenant-context
+ Signing alg RS256 (JWKS, rotated 90d)
+
+
+
+
+
+
+
+
+ Keycloak group Maps to
+
+
+ {groupMappings.map(([g, role]) => (
+
+ {g}
+ {role}
+
+ ))}
+
+
+
+
+
+
);
}
diff --git a/src/app/[slug]/settings/page.tsx b/src/app/[slug]/settings/page.tsx
index 9d76c9f..f599116 100644
--- a/src/app/[slug]/settings/page.tsx
+++ b/src/app/[slug]/settings/page.tsx
@@ -1,120 +1,118 @@
-import { auth } from "@/auth";
-import { NotAuthorized } from "@/components/ShellEmpty";
-import { formatDateTime } from "@/lib/format";
-import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
-import { fetchTenantBySlug } from "@/lib/tenant-registry";
+import { getPortalSession } from "@/lib/get-session";
+import { loadTenantForShell } from "@/lib/portal-data";
+import { Panel } from "@/components/portal/Panel";
+import { Monogram } from "@/components/portal/Monogram";
+import { NotAllowed } from "@/components/portal/NotAllowed";
-export default async function SettingsPage({
+// "Organization" — IT_ADMIN only.
+export default async function OrganizationPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
- const session = (await auth()) as SessionWithExtras | null;
- if (!canSee(session, "settings")) return
;
+ const session = await getPortalSession();
+ if (!canSee(session, "settings")) return
;
+ const t = await loadTenantForShell(slug);
+ if (!t) return null;
- const tenant = await fetchTenantBySlug(slug);
- if (!tenant) {
- return (
-
- Settings
- Tenant not found.
-
- );
- }
+ const subscribed = t.products.filter((p) => t.entitled.includes(p.id));
+ const trialing = t.products.filter((p) => t.trialing.includes(p.id));
+ const seatsLeft = t.seats.total - t.seats.used;
+ const pct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
return (
-
- Settings
-
- Tenant identity and lifecycle metadata. Editing these lands in the
- M10.1 follow-up; for now contact support .
-
+
+
+
+
Organization
+
+ Tenant profile, entitlements & primary contact
+
+
+
+ Export profile
+ Edit details
+
+
-
Identity
-
-
-
-
+
+
+
+
+ Legal name {t.name}
+ Form {t.legalType}
+ Registered {t.city} · {t.country}
+ VAT ID {t.vat}
+ Tenant ID {t.id}
+ Customer since {t.since}
+
+
-
Plan & status
-
-
- {tenant.trial_ends_at && (
-
- )}
+
+
+
+ {t.contact.split(" ").map((s) => s[0]).join("")}
+
+
+
{t.contact}
+
{t.contactEmail}
+
+
+
ADMIN OWNER
+
+
+
-
Audit
-
-
+
+
+
+
+
PLAN
+
{t.plan}
+
{t.planCode}
+
+
+
+
+ SEATS
+ {t.seats.used} / {t.seats.total}
+
+
+ {seatsLeft} seats available
+
-
External links
-
- ERPNext customer + Polar subscription land in M8.3; rendered here when
- the IDs land on the tenant row.
-
-
- );
-}
-
-function Field({
- label,
- value,
- mono,
- badge,
-}: {
- label: string;
- value: string;
- mono?: boolean;
- badge?: string;
-}) {
- return (
-
-
{label}
-
- {badge ? (
-
- {value}
-
- ) : (
- value
- )}
-
+
{subscribed.length} active} pad={false}>
+
+ {subscribed.map((p) => (
+
+
+
+ {p.name}
+
+ ENTITLED
+
+ ))}
+ {trialing.map((p) => (
+
+
+
+ {p.name}
+
+ TRIALING
+
+ ))}
+ {subscribed.length === 0 && trialing.length === 0 ? (
+
No active products.
+ ) : null}
+
+
+
+
);
}
-
-function statusColor(s: string): string {
- switch (s) {
- case "active":
- return "#1a7a3e";
- case "trial":
- return "#a87a00";
- case "frozen":
- return "#a82626";
- case "archived":
- return "#666";
- case "demo":
- return "#0070f3";
- default:
- return "#444";
- }
-}
diff --git a/src/app/[slug]/settings/users/page.tsx b/src/app/[slug]/settings/users/page.tsx
index 62a3ab2..8b58fc6 100644
--- a/src/app/[slug]/settings/users/page.tsx
+++ b/src/app/[slug]/settings/users/page.tsx
@@ -1,17 +1,79 @@
-import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
-import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
+import { getPortalSession } from "@/lib/get-session";
+import { loadTenantForShell } from "@/lib/portal-data";
+import { Panel } from "@/components/portal/Panel";
+import { NotAllowed } from "@/components/portal/NotAllowed";
+
+export default async function TeamPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ const session = await getPortalSession();
+ if (!canSee(session, "users")) return
;
+ const t = await loadTenantForShell(slug);
+ if (!t) return null;
+ const team = t.team;
-export default async function Page() {
- const session = (await auth()) as SessionWithExtras | null;
- if (!canSee(session, "users")) return
;
return (
-
+
+
+
+
Team
+
+ {team.length} members ·{" "}
+ {t.seats.used}/{t.seats.total} seats used
+
+
+
+ Export
+ Invite member
+
+
+
+
+
+
+
+ Member
+ Email
+ Roles
+ Last active
+ Status
+
+
+
+ {team.map((m, i) => (
+
+
+
+
+ {m.name.split(" ").map((s) => s[0]).join("")}
+
+ {m.name}
+
+
+ {m.email}
+
+
+ {m.roles.map((r) => (
+ {r}
+ ))}
+
+
+ {m.last}
+
+
+
+ {m.status === "invited" ? "Invited" : "Active"}
+
+
+
+ ))}
+
+
+
+
);
}
diff --git a/src/components/portal/NotAllowed.tsx b/src/components/portal/NotAllowed.tsx
new file mode 100644
index 0000000..ad20876
--- /dev/null
+++ b/src/components/portal/NotAllowed.tsx
@@ -0,0 +1,25 @@
+// Body of any route the current session can't open. Mirrors the design's
+// 404 treatment so the surface stays in the ledger language.
+export function NotAllowed({ need }: { need: string }) {
+ return (
+
+
+
+
403 · NOT AUTHORIZED
+
+ 403
+
+
+ Your roles don't include access to this screen.
+
+
+ Requires {need}
+
+
+
+
+ );
+}