M10.2 design system — tokens, shell + 7 customer-area screens restyled #13

Merged
sharang merged 7 commits from feat/m10.2-design-system into main 2026-06-04 16:10:52 +00:00
7 changed files with 644 additions and 436 deletions
Showing only changes of commit 91a655b6df - Show all commits
+80 -186
View File
@@ -1,207 +1,101 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { NotAuthorized } from "@/components/ShellEmpty";
import { formatDateTime, formatRelative, truncate } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
import { fetchAudit, fetchTenantBySlug } from "@/lib/tenant-registry";
import { getPortalSession } from "@/lib/get-session";
import { loadTenantForShell } from "@/lib/portal-data";
import { Panel } from "@/components/portal/Panel";
import { NotAllowed } from "@/components/portal/NotAllowed";
const PAGE_SIZE = 50;
const EVENT_FILTERS = ["all", "auth", "scan", "finding", "evidence", "billing", "settings"];
export default async function AuditPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ cursor?: string; action?: string; actor_id?: string }>;
searchParams: Promise<{ q?: string; type?: string; product?: string }>;
}) {
const { slug } = await params;
const q = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "audit")) return <NotAuthorized />;
const sp = await searchParams;
const session = await getPortalSession();
if (!canSee(session, "audit")) return <NotAllowed need="LEGAL / IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) redirect(`/${slug}/dashboard`);
const q = sp.q?.toLowerCase() ?? "";
const type = sp.type ?? "all";
const product = sp.product ?? "all";
const cursor = q.cursor ? Number(q.cursor) : undefined;
const page = await fetchAudit({
tenant_id: tenant.id,
action: q.action || undefined,
actor_id: q.actor_id || undefined,
limit: PAGE_SIZE,
cursor: cursor && !Number.isNaN(cursor) ? cursor : undefined,
const rows = t.audit.filter((r) => {
if (q && !`${r.event} ${r.actor} ${r.product}`.toLowerCase().includes(q)) return false;
if (type !== "all" && !r.event.startsWith(type)) return false;
if (product !== "all" && r.product !== product) return false;
return true;
});
const nextHref = page.next_cursor
? buildHref(slug, { ...q, cursor: String(page.next_cursor) })
: null;
const resetHref = (q.action || q.actor_id || q.cursor) ? `/${slug}/audit` : null;
return (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Audit log</h1>
<p style={{ color: "#444", marginBottom: 16 }}>
Every state-changing action emitted by the portal and the products.{" "}
<a
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PRODUCT_INTEGRATION_SPEC.md"
style={{ color: "#0070f3" }}
>
Retraced-shape schema
</a>{" "}
CSV / PDF export lands in M10.2.
</p>
<Filters slug={slug} active={{ action: q.action, actor_id: q.actor_id }} />
{page.items.length === 0 ? (
<p style={{ color: "#666", fontSize: 14, marginTop: 16 }}>
No events match the current filter.
</p>
) : (
<div style={{ overflow: "auto", marginTop: 16 }}>
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "1px solid #eaeaea" }}>
<th style={th}>When</th>
<th style={th}>Action</th>
<th style={th}>Actor</th>
<th style={th}>Target</th>
<th style={th}>Product</th>
<th style={th}>Meta</th>
</tr>
</thead>
<tbody>
{page.items.map((ev) => (
<tr key={ev.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={td} title={formatDateTime(ev.created_at)}>
{formatRelative(ev.created_at)}
</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace" }}>
{ev.action}
</td>
<td style={td}>
{ev.actor_name || ev.actor_id || (
<em style={{ color: "#999" }}>system</em>
)}
</td>
<td style={td}>
{ev.target_type && (
<span style={{ color: "#666" }}>{ev.target_type}:</span>
)}{" "}
{ev.target_name || ev.target_id || (
<em style={{ color: "#999" }}></em>
)}
</td>
<td style={td}>
{ev.product || <em style={{ color: "#999" }}>portal</em>}
</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace", color: "#666" }}>
{ev.metadata && Object.keys(ev.metadata).length > 0
? truncate(JSON.stringify(ev.metadata), 50)
: ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: 16, display: "flex", justifyContent: "space-between" }}>
<div className="content-inner">
<div className="page-head">
<div>
{resetHref && (
<Link href={resetHref as `/${string}`} style={btnLink}>
Clear filters
</Link>
)}
<div className="page-title">Audit log</div>
<div className="page-sub">
<span className="mono">{rows.length}</span> of <span className="mono">{t.audit.length}</span>{" "}
events · retention 365 days · hash-chained
</div>
</div>
<div>
{nextHref && (
<Link href={nextHref as `/${string}`} style={btnLink}>
Next page
</Link>
)}
<div className="ph-actions">
<button type="button" className="btn">Export (CSV)</button>
</div>
</div>
</section>
<Panel pad={false}>
<form method="get" className="row" style={{ gap: 10, padding: "12px 14px", borderBottom: "1px solid var(--rule)", flexWrap: "wrap" }}>
<input name="q" defaultValue={sp.q ?? ""} placeholder="Search events…" className="input mono" style={{ width: 260, fontSize: 12 }} />
<span className="row" style={{ gap: 4 }}>
{EVENT_FILTERS.map((f) => (
<button key={f} name="type" value={f} type="submit" className={"btn btn-sm" + (type === f ? " btn-primary" : "")}>
{f}
</button>
))}
</span>
<span className="spacer" />
<select name="product" defaultValue={product} className="input" style={{ width: 200, fontSize: 12 }}>
<option value="all">All products</option>
{t.products.filter((p) => t.entitled.includes(p.id)).map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
<option value="—">platform / </option>
</select>
</form>
<table className="ltable">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Product</th>
<th>Source IP</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 50).map((r, i) => (
<tr key={i}>
<td className="mono t-dim" style={{ whiteSpace: "nowrap" }}>{r.date} {r.time}</td>
<td className="mono" style={{ fontSize: 11.5 }}>{r.event}</td>
<td>{r.actor}</td>
<td className="mono t-dim">{r.product}</td>
<td className="mono t-dim">{r.ip}</td>
<td>
<span className="row" style={{ gap: 6, fontSize: 12 }}>
<span className={`dot ${r.result === "denied" ? "danger" : "ok"}`} />
{r.result === "denied" ? "DENIED" : "OK"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
);
}
function Filters({
slug,
active,
}: {
slug: string;
active: { action?: string; actor_id?: string };
}) {
return (
<form
action={`/${slug}/audit`}
method="GET"
style={{
display: "flex",
gap: 8,
marginTop: 8,
padding: 12,
background: "#fafafa",
border: "1px solid #eaeaea",
borderRadius: 6,
}}
>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
action
<input
name="action"
defaultValue={active.action ?? ""}
placeholder="tenant.created"
style={{ ...inputStyle, padding: "4px 8px", fontSize: 13 }}
/>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
actor_id
<input
name="actor_id"
defaultValue={active.actor_id ?? ""}
placeholder="kc user id"
style={{ ...inputStyle, padding: "4px 8px", fontSize: 13 }}
/>
</label>
<button type="submit" style={btnSmall}>
Filter
</button>
</form>
);
}
function buildHref(slug: string, q: Record<string, string | undefined>): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(q)) {
if (v) qs.set(k, v);
}
const s = qs.toString();
return s ? `/${slug}/audit?${s}` : `/${slug}/audit`;
}
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 14,
};
const btnLink: React.CSSProperties = {
color: "#0070f3",
fontSize: 13,
textDecoration: "none",
};
const btnSmall: React.CSSProperties = {
padding: "4px 10px",
background: "white",
color: "#0070f3",
border: "1px solid #0070f3",
borderRadius: 4,
fontSize: 12,
cursor: "pointer",
};
const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 };
const td: React.CSSProperties = { padding: "8px 10px" };
+150 -21
View File
@@ -1,31 +1,160 @@
import Link from "next/link";
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";
import { CreditCard, AlertTriangle } from "lucide-react";
export default async function Page({
const EUR = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
export default async function BillingPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "billing")) return <NotAuthorized />;
const session = await getPortalSession();
if (!canSee(session, "billing")) return <NotAllowed need="CXO / FINANCE / IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const monthlyVAT = Math.round(t.monthly * 0.19);
const monthlyGross = t.monthly + monthlyVAT;
const seatsPct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
const evidenceStorageUsed = t.metrics.evidence;
const evidenceStorageMax = 1000;
const evidencePct = (evidenceStorageUsed / evidenceStorageMax) * 100;
const frozen = t.status === "frozen";
return (
<ShellEmpty
title="Billing"
description="Plan, seats, invoices. Polar Checkout opens here for plan changes."
milestone="M8.3"
details="Polar.sh is the Merchant of Record — it handles EU VAT MOSS so we don't have to. Invoices mirror to ERPNext (Customer + Sales Invoice) so accounting stays in one place."
cta={
<Link
href={`/${slug}/catalog`}
style={{ color: "#0070f3", fontSize: 14, textDecoration: "underline" }}
>
Browse the catalog
</Link>
}
/>
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">Billing</div>
<div className="page-sub">Plan, usage, payment method and invoice history</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Download all (PDF)</button>
</div>
</div>
<div className="grid g-12" style={{ marginBottom: 14 }}>
<div className="span-7 col" style={{ gap: 12 }}>
<Panel title="Current plan" bracket>
<div className="row between" style={{ alignItems: "flex-start" }}>
<div>
<div className="eyebrow">PLAN</div>
<div style={{ fontSize: 20, fontWeight: 600, marginTop: 3 }}>{t.plan}</div>
<div className="mono muted" style={{ fontSize: 11 }}>{t.planCode}</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="eyebrow">MONTHLY</div>
<div className="mono" style={{ fontSize: 22, fontWeight: 500, marginTop: 3 }}>
{EUR.format(t.monthly)}
</div>
<div className="mono muted" style={{ fontSize: 10 }}>
+ 19% VAT = {EUR.format(monthlyGross)}
</div>
</div>
</div>
<div className="divider" style={{ margin: "14px 0" }} />
<div className="row between" style={{ marginBottom: 6 }}>
<span className="label-micro">SEATS</span>
<span className="mono" style={{ fontSize: 12 }}>{t.seats.used} / {t.seats.total}</span>
</div>
<div className="meter"><span style={{ width: `${seatsPct}%` }} /></div>
<div className="row between" style={{ margin: "12px 0 6px" }}>
<span className="label-micro">EVIDENCE STORAGE</span>
<span className="mono" style={{ fontSize: 12 }}>
{evidenceStorageUsed} / {evidenceStorageMax} GB
</span>
</div>
<div className={`meter ${evidencePct > 90 ? "warn" : ""}`}>
<span style={{ width: `${evidencePct}%` }} />
</div>
</Panel>
<Panel title="Payment method">
{frozen ? (
<div className="row" style={{ gap: 11 }}>
<span className="row" style={{ gap: 8, color: "var(--danger)", fontSize: 13, fontWeight: 600 }}>
<AlertTriangle size={16} /> Payment failed
</span>
<span className="spacer" />
<button type="button" className="btn btn-accent">Re-activate to continue</button>
</div>
) : (
<div className="row" style={{ gap: 11 }}>
<span className="brand-mark" style={{ background: "var(--paper-2)", color: "var(--ink-2)" }}>
<CreditCard size={14} />
</span>
<div>
<div style={{ fontWeight: 600 }}>SEPA Direct Debit</div>
<div className="mono muted" style={{ fontSize: 11 }}>
DE89 7421 · {t.contact}
</div>
</div>
<span className="spacer" />
<button type="button" className="btn btn-sm">Replace</button>
</div>
)}
</Panel>
</div>
<div className="span-5 col" style={{ gap: 12 }}>
<Panel title="Renewal">
<dl className="dl">
<dt>Renews on</dt>
<dd className="mono" style={{ color: t.renewal === "overdue" ? "var(--danger)" : "inherit" }}>
{t.renewal}
</dd>
<dt>Billing email</dt><dd className="mono">{t.contactEmail}</dd>
<dt>Currency</dt><dd className="mono">EUR</dd>
<dt>VAT applied</dt><dd className="mono">19% (DE)</dd>
</dl>
</Panel>
</div>
</div>
<Panel title="Invoices" tail={<span className="label-micro">{t.invoices.length} entries</span>} pad={false}>
<table className="ltable">
<thead>
<tr>
<th>Invoice</th>
<th>Period</th>
<th>Issued</th>
<th className="r">Seats</th>
<th className="r">Net</th>
<th className="r">VAT</th>
<th className="r">Total</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{t.invoices.map((inv) => (
<tr key={inv.id} className="clickable">
<td className="t-id">{inv.id}</td>
<td>{inv.period}</td>
<td className="mono t-dim">{inv.issued}</td>
<td className="r mono">{inv.seats}</td>
<td className="r mono">{EUR.format(inv.net)}</td>
<td className="r mono t-dim">{EUR.format(inv.vat)}</td>
<td className="r mono" style={{ fontWeight: 600 }}>{EUR.format(inv.total)}</td>
<td>
<span className="row" style={{ gap: 6, fontSize: 12 }}>
<span className={`dot ${inv.status === "due" ? "warn" : "ok"}`} />
{inv.status === "due" ? "Due" : "Paid"}
</span>
</td>
<td className="r">
<button type="button" className="btn btn-sm btn-ghost">PDF</button>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
);
}
+143 -102
View File
@@ -1,118 +1,159 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { NotAuthorized } from "@/components/ShellEmpty";
import { formatDateTime, formatRelative } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { canSee } from "@/lib/session";
import {
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
type CatalogEntry,
type Entitlement,
} 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 { Sev } from "@/components/portal/Sev";
import { NotAllowed } from "@/components/portal/NotAllowed";
export default async function ProductsPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ p?: string }>;
}) {
const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "products")) return <NotAuthorized />;
const sp = await searchParams;
const session = await getPortalSession();
if (!canSee(session, "products")) return <NotAllowed need="USER+" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) redirect(`/${slug}/dashboard`);
const [catalog, entitlements] = await Promise.all([
fetchCatalog(),
fetchEntitlements(tenant.id),
]);
const byKey = new Map(catalog.map((c) => [c.key, c]));
const active = entitlements.filter((e) => e.enabled);
const productFilter = sp.p ?? "all";
const findings = t.findings.filter((f) => productFilter === "all" || f.product === productFilter);
return (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Products</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Live entitlements for <strong>{tenant.name}</strong>. Open a product to
use its web component (M6.x / M7.x).
</p>
{active.length === 0 ? (
<div
style={{
padding: 16,
border: "1px dashed #ddd",
borderRadius: 8,
background: "#fafafa",
color: "#666",
fontSize: 14,
}}
>
<p style={{ marginBottom: 8 }}>No products yet.</p>
<a href={`/${slug}/catalog`} style={{ color: "#0070f3" }}>
Browse the catalog
</a>
<div className="content-inner">
<div className="page-head">
<div>
<div className="page-title">Products</div>
<div className="page-sub">
{t.entitled.length} entitled · click a product to open its launch screen
</div>
</div>
) : (
<ul
style={{
listStyle: "none",
padding: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
}}
>
{active.map((e) => (
<ProductCard key={e.product} ent={e} catalog={byKey.get(e.product)} />
))}
</ul>
)}
</section>
);
}
</div>
function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) {
return (
<li
style={{
padding: 16,
border: "1px solid #eaeaea",
borderRadius: 8,
background: "white",
}}
>
<div
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
>
<strong style={{ fontSize: 15 }}>{catalog?.name ?? ent.product}</strong>
{ent.expires_at && (
<span
title={formatDateTime(ent.expires_at)}
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 4,
background: "#fff7e0",
color: "#7a5a00",
}}
>
trial · {formatRelative(ent.expires_at)}
<div className="product-grid" style={{ marginBottom: 14 }}>
{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 (
<div key={p.id} className="pcard soon">
<div className="pcard-top">
<Monogram text={p.mono} size={36} variant="soon" />
<div className="pc-titles">
<span className="pcard-title">{p.name}</span>
<span className="pcard-slug">{p.slug}</span>
</div>
</div>
<p className="muted" style={{ fontSize: 12, lineHeight: 1.5 }}>{p.blurb}</p>
<div className="mono" style={{ fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--ink-3)" }}>
Coming soon
</div>
</div>
);
}
return (
<Link key={p.id} href={`/${slug}/products?p=${p.slug}`} className="pcard">
<div className="pcard-top">
<Monogram text={p.mono} size={36} />
<div className="pc-titles">
<span className="pcard-title">{p.name}</span>
<span className="pcard-slug">{p.slug}</span>
</div>
<span className="pcard-cta"><ArrowRight size={14} /></span>
</div>
<p className="muted" style={{ fontSize: 12, lineHeight: 1.5, margin: 0 }}>{p.blurb}</p>
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{p.frameworks.map((f) => (
<span key={f} className="role-chip">{f}</span>
))}
</div>
<div className="pcard-stats">
<div className="pstat">
<div className="ps-v">{openCount}</div>
<div className="ps-l">Open</div>
</div>
<div className="pstat">
<div className="ps-v">{evidence}</div>
<div className="ps-l">Evidence</div>
</div>
<div className="pstat">
<div className="ps-v" style={{ fontSize: 12, paddingTop: 2 }}>
{entitled ? (
<span className="row" style={{ gap: 4 }}><span className="dot ok" />Entitled</span>
) : trialing ? (
<span className="row" style={{ gap: 4 }}><span className="dot warn" />Trialing</span>
) : null}
</div>
<div className="ps-l">Plan</div>
</div>
</div>
</Link>
);
})}
</div>
<Panel
title="Findings across products"
tail={
<span className="row" style={{ gap: 6 }}>
{["all", "compliance-scanner", "certifai"].map((o) => (
<Link
key={o}
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
>
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
</Link>
))}
</span>
)}
</div>
{catalog?.description && (
<p style={{ color: "#666", fontSize: 13, marginTop: 6, marginBottom: 12 }}>
{catalog.description}
</p>
)}
<div style={{ fontSize: 12, color: "#999", marginTop: 8 }}>
Web component renders here once <code>{ent.product}-dashboard</code> is
registered (M6.3 / M7.2).
</div>
</li>
}
pad={false}
>
<table className="ltable">
<thead>
<tr>
<th>Sev</th>
<th>ID</th>
<th>Title</th>
<th>Product</th>
<th>Control</th>
<th className="r">Age</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{findings.slice(0, 12).map((f) => (
<tr key={f.id} className="clickable">
<td><Sev level={f.severity} /></td>
<td className="t-id">{f.id}</td>
<td style={{ maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{f.title}
</td>
<td className="mono t-dim">{f.product}</td>
<td className="mono t-dim">{f.control}</td>
<td className="r mono">{f.ageDays}d</td>
<td>
<span className="row" style={{ gap: 6, fontSize: 12 }}>
<span className={`dot ${f.status === "resolved" ? "ok" : "danger"}`} />
{f.status === "resolved" ? "Resolved" : "Open"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
);
}
+71 -12
View File
@@ -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 <NotAllowed need="IT_ADMIN" />;
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 <NotAuthorized />;
return (
<ShellEmpty
title="Integrations"
description="Webhooks, outbound integrations, and external IdP configuration."
milestone="M15.2"
details="Webhook delivery (signed payloads, retry-with-backoff, dead-letter queue) lands alongside the headless-product API surface."
/>
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">SSO</div>
<div className="page-sub">Single sign-on via Keycloak (OIDC) managed by Breakpilot Platform</div>
</div>
<div className="ph-actions">
<span className="row" style={{ gap: 8 }}>
<span className="dot ok" />
<span className="mono" style={{ fontSize: 11 }}>connection healthy</span>
</span>
</div>
</div>
<div className="grid g-12">
<div className="span-7 col" style={{ gap: 12 }}>
<Panel title="OIDC summary" bracket>
<dl className="dl">
<dt>Provider</dt><dd>Keycloak (breakpilot-dev realm)</dd>
<dt>Protocol</dt><dd className="mono">OpenID Connect / authorization_code + PKCE</dd>
<dt>Issuer</dt><dd className="mono">{process.env.KEYCLOAK_ISSUER ?? "http://localhost:8080/realms/breakpilot-dev"}</dd>
<dt>Client ID</dt><dd className="mono">{process.env.KEYCLOAK_CLIENT_ID ?? "dev-portal"}</dd>
<dt>Redirect URI</dt><dd className="mono">{`https://${t.id}.breakpilot.eu/api/auth/callback/keycloak`}</dd>
<dt>Scopes</dt><dd className="mono">openid profile email tenant-context</dd>
<dt>Signing alg</dt><dd className="mono">RS256 (JWKS, rotated 90d)</dd>
</dl>
</Panel>
</div>
<div className="span-5 col" style={{ gap: 12 }}>
<Panel title="IdP group → role mapping" pad={false}>
<table className="ltable">
<thead>
<tr><th>Keycloak group</th><th>Maps to</th></tr>
</thead>
<tbody>
{groupMappings.map(([g, role]) => (
<tr key={g}>
<td className="mono t-id">{g}</td>
<td><span className="role-chip">{role}</span></td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
</div>
</div>
);
}
+101 -103
View File
@@ -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 <NotAuthorized />;
const session = await getPortalSession();
if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) {
return (
<section>
<h1 style={{ fontSize: 28 }}>Settings</h1>
<p style={{ color: "#a82626" }}>Tenant not found.</p>
</section>
);
}
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 (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Settings</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Tenant identity and lifecycle metadata. Editing these lands in the
M10.1 follow-up; for now contact <a href={`/${slug}/support`}>support</a>.
</p>
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">Organization</div>
<div className="page-sub">
Tenant profile, entitlements & primary contact
</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Export profile</button>
<button type="button" className="btn btn-primary">Edit details</button>
</div>
</div>
<h2 style={{ fontSize: 18, marginTop: 16, marginBottom: 8 }}>Identity</h2>
<Field label="Tenant ID" value={tenant.id} mono />
<Field label="Slug" value={tenant.slug} mono />
<Field label="Name" value={tenant.name} />
<Field label="Kind" value={tenant.kind} />
<div className="grid g-12">
<div className="span-7 col" style={{ gap: 12 }}>
<Panel title="Legal entity" bracket>
<dl className="dl">
<dt>Legal name</dt><dd>{t.name}</dd>
<dt>Form</dt><dd className="mono">{t.legalType}</dd>
<dt>Registered</dt><dd>{t.city} · {t.country}</dd>
<dt>VAT ID</dt><dd className="mono">{t.vat}</dd>
<dt>Tenant ID</dt><dd className="mono">{t.id}</dd>
<dt>Customer since</dt><dd className="mono">{t.since}</dd>
</dl>
</Panel>
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>Plan & status</h2>
<Field label="Plan" value={tenant.plan} />
<Field label="Status" value={tenant.status} badge={statusColor(tenant.status)} />
{tenant.trial_ends_at && (
<Field label="Trial ends at" value={formatDateTime(tenant.trial_ends_at)} mono />
)}
<Panel title="Primary contact">
<div className="row" style={{ gap: 12 }}>
<span className="avatar" style={{ width: 38, height: 38, fontSize: 13 }}>
{t.contact.split(" ").map((s) => s[0]).join("")}
</span>
<div>
<div style={{ fontWeight: 600 }}>{t.contact}</div>
<div className="mono muted" style={{ fontSize: 12 }}>{t.contactEmail}</div>
</div>
<span className="spacer" />
<span className="tag"><span className="dot accent" /> ADMIN OWNER</span>
</div>
</Panel>
</div>
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>Audit</h2>
<Field label="Created" value={formatDateTime(tenant.created_at)} mono />
<Field label="Last updated" value={formatDateTime(tenant.updated_at)} mono />
<div className="span-5 col" style={{ gap: 12 }}>
<Panel title="Plan & seats">
<div className="row between" style={{ alignItems: "flex-start", marginBottom: 14 }}>
<div>
<div className="eyebrow">PLAN</div>
<div style={{ fontSize: 17, fontWeight: 600, marginTop: 3 }}>{t.plan}</div>
<div className="mono muted" style={{ fontSize: 11 }}>{t.planCode}</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="eyebrow">RENEWS</div>
<div className="mono" style={{ fontSize: 14, marginTop: 3, color: t.renewal === "overdue" ? "var(--danger)" : "inherit" }}>{t.renewal}</div>
</div>
</div>
<div className="row between" style={{ marginBottom: 6 }}>
<span className="label-micro">SEATS</span>
<span className="mono" style={{ fontSize: 12 }}>{t.seats.used} / {t.seats.total}</span>
</div>
<div className="meter"><span style={{ width: `${pct}%` }} /></div>
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 6 }}>{seatsLeft} seats available</div>
</Panel>
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>External links</h2>
<p style={{ fontSize: 13, color: "#666" }}>
ERPNext customer + Polar subscription land in M8.3; rendered here when
the IDs land on the tenant row.
</p>
</section>
);
}
function Field({
label,
value,
mono,
badge,
}: {
label: string;
value: string;
mono?: boolean;
badge?: string;
}) {
return (
<div style={{ display: "flex", padding: "6px 0", borderBottom: "1px solid #f0f0f0" }}>
<span style={{ width: 160, color: "#666", fontSize: 13 }}>{label}</span>
<span
style={{
fontFamily: mono
? "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
: "inherit",
fontSize: mono ? 13 : 14,
}}
>
{badge ? (
<span
style={{
padding: "2px 8px",
borderRadius: 4,
background: badge,
color: "#fff",
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{value}
</span>
) : (
value
)}
</span>
<Panel title="Products subscribed" tail={<span className="label-micro">{subscribed.length} active</span>} pad={false}>
<div className="kv-list" style={{ padding: "2px 14px" }}>
{subscribed.map((p) => (
<div className="kv" key={p.id}>
<span className="row" style={{ gap: 9 }}>
<Monogram text={p.mono} size={24} />
<span className="kvk" style={{ fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap" }}>{p.name}</span>
</span>
<span className="tag"><span className="dot ok" /> ENTITLED</span>
</div>
))}
{trialing.map((p) => (
<div className="kv" key={p.id}>
<span className="row" style={{ gap: 9 }}>
<Monogram text={p.mono} size={24} />
<span className="kvk" style={{ fontWeight: 500, color: "var(--ink)" }}>{p.name}</span>
</span>
<span className="tag"><span className="dot warn" /> TRIALING</span>
</div>
))}
{subscribed.length === 0 && trialing.length === 0 ? (
<div className="muted" style={{ padding: "12px 0", fontSize: 12 }}>No active products.</div>
) : null}
</div>
</Panel>
</div>
</div>
</div>
);
}
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";
}
}
+74 -12
View File
@@ -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 <NotAllowed need="IT_ADMIN" />;
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 <NotAuthorized />;
return (
<ShellEmpty
title="Users"
description="Invite teammates as IT_ADMIN, CXO, FINANCE, LEGAL, or USER."
milestone="M10.1 follow-up"
details="User management calls Keycloak's Organizations Admin API. The adapter exists (internal/keycloak in tenant-registry); this UI just needs the list + invite handlers wired."
/>
<div className="content-inner">
<div className="page-head">
<div>
<div className="page-title">Team</div>
<div className="page-sub">
{team.length} members ·{" "}
<span className="mono">{t.seats.used}/{t.seats.total}</span> seats used
</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Export</button>
<button type="button" className="btn btn-accent">Invite member</button>
</div>
</div>
<Panel bracket pad={false}>
<table className="ltable">
<thead>
<tr>
<th>Member</th>
<th>Email</th>
<th>Roles</th>
<th>Last active</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{team.map((m, i) => (
<tr key={i}>
<td>
<span className="row" style={{ gap: 9 }}>
<span className="avatar" style={{ width: 24, height: 24, fontSize: 9 }}>
{m.name.split(" ").map((s) => s[0]).join("")}
</span>
<span style={{ fontWeight: 500, whiteSpace: "nowrap" }}>{m.name}</span>
</span>
</td>
<td className="mono t-dim" style={{ fontSize: 11.5 }}>{m.email}</td>
<td>
<span className="row wrap" style={{ gap: 4 }}>
{m.roles.map((r) => (
<span key={r} className="role-chip">{r}</span>
))}
</span>
</td>
<td className="mono t-dim">{m.last}</td>
<td>
<span className="row" style={{ gap: 6, fontSize: 12 }}>
<span className={`dot ${m.status === "invited" ? "warn" : "ok"}`} />
{m.status === "invited" ? "Invited" : "Active"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
);
}
+25
View File
@@ -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 (
<div className="error-page">
<div className="lockout-card panel bracket">
<div className="panel-pad" style={{ padding: 24 }}>
<div className="eyebrow">403 · NOT AUTHORIZED</div>
<h1 className="error-code" style={{ marginTop: 6 }}>
403
</h1>
<p style={{ color: "var(--ink-2)", marginTop: 6 }}>
Your roles don&apos;t include access to this screen.
</p>
<p
className="mono"
style={{ fontSize: 11, color: "var(--ink-3)", marginTop: 4 }}
>
Requires <span style={{ color: "var(--ink)" }}>{need}</span>
</p>
</div>
</div>
</div>
);
}