feat(portal): M10.2 design system foundations — tokens, shell, Dashboard
Brings in the handoff design system from `Breakpilot Platform.zip` (`breakpilot/design_handoff_customer_portal/`) as the base for restyling every customer-area surface. What's in: * **Design tokens & layout primitives** — `src/app/globals.css` is the handoff `styles.css` in full (OKLCH paper + ink + brand-violet, --rule-* hairlines, --sev-* severity ramp, corner-tick bracket treatment, ledger table, 32–36px row density, dark mode via `[data-theme="dark"]`). Tailwind v4 layered on top via PostCSS for utility helpers; the design system itself stays in plain CSS. * **Geist + Geist Mono** wired through `next/font/google` so the monospaced metadata/figures everywhere render at the intended weight. * **Shell chrome** under `src/components/portal/`: `Brand` (Breakpilot. wordmark with the violet trailing dot), `Lifeline` (top full-width tenant rail — active / trial / frozen / demo variants; archived swaps in `ArchivedLockout`), `NavRail` (232px left rail with tenant switcher + workspace/admin/ settings groups + user chip; locked routes show a lock icon and a "Requires X" tooltip rather than vanishing), `Topbar` (breadcrumb + ⌘K button placeholder + theme toggle), `ThemeToggle` (Sun/Moon, persists to `localStorage["bp.theme"]`, no-flash via a head script in the root layout). * **Dashboard** at `/[slug]/dashboard` rebuilt per handoff §1: page-head with Export + Run scan (the latter wrapped in the frozen write-guard hovercard surfacing `HTTP 402 · payment required`), 5-cell bracketed KPI rail (open findings + 14-day sparkbars + 7-day delta, critical with severity stack, controls passing with violet ring gauge + n/240, evidence area sparkline, last-scan cadence), 12-col grid: 30-day findings flow + severity stack legend + top-5 open findings table on the left, product posture rows + scan-activity heatmap (5x7) + recent-activity feed on the right. Plain USER role drops the KPI rail and the org-wide panels per spec. * **Charts** — minimal SVG primitives in `components/portal/charts/`: Sparkbars, Sparkline (area + line), Ring, StackBar, Heatmap + HeatLegend. All token-driven (`var(--sev-*)`, `var(--accent)`). * **Fixtures** — `src/lib/fixtures.ts` is a TS port of the handoff's `data.js`. Deterministic mulberry32 generators give the same realistic DACH/EU compliance data every reload (~5 tenants × 30+ days activity / 4–13 findings per product / 9 months invoices / hash- chained audit). Source of truth for the design until tenant-registry is enriched to carry these fields end-to-end. RBAC table (`canAccess`, `landingFor`) ported alongside. * **Dev session bypass** — `src/lib/get-session.ts` returns a synthetic `SessionWithExtras` from one of the 6 fixtures when `BP_DEV_FIXTURE=<id>` is set. Lets the portal render the design without Keycloak + tenant-registry up. Real Auth.js wiring untouched. What's NOT in yet (next commits): * Products / Product launch / Org / Team / Billing / Audit / SSO pages * Workflows editor (palette + canvas + inspector + drag-wiring) * Command palette + toast system * MSW handlers for the tenant data shapes (today the page reads the fixture module directly server-side; MSW is for client-side calls) Run locally: pnpm install BP_DEV_FIXTURE=admin-acme pnpm dev open http://acme.localhost:3000/acme/dashboard Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+346
-144
@@ -1,7 +1,24 @@
|
||||
import { auth, signIn, signOut } from "@/auth";
|
||||
import { ShellEmpty } from "@/components/ShellEmpty";
|
||||
import type { SessionWithExtras } from "@/lib/session";
|
||||
import { fetchTenantBySlug } from "@/lib/tenant-registry";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Download, Play, ShieldAlert } from "lucide-react";
|
||||
import { signIn } from "@/auth";
|
||||
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 { Sparkbars } from "@/components/portal/charts/Sparkbars";
|
||||
import { Sparkline } from "@/components/portal/charts/Sparkline";
|
||||
import { Ring } from "@/components/portal/charts/Ring";
|
||||
import { StackBar } from "@/components/portal/charts/StackBar";
|
||||
import { Heatmap, HeatLegend } from "@/components/portal/charts/Heatmap";
|
||||
import { productById, type Severity } from "@/lib/fixtures";
|
||||
|
||||
const SEV_LABEL: Record<Severity, string> = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
};
|
||||
|
||||
export default async function Dashboard({
|
||||
params,
|
||||
@@ -9,7 +26,9 @@ export default async function Dashboard({
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const session = (await auth()) as SessionWithExtras | null;
|
||||
const session = await getPortalSession();
|
||||
const tenant = await loadTenantForShell(slug);
|
||||
if (!tenant) return null;
|
||||
|
||||
if (!session) {
|
||||
async function login() {
|
||||
@@ -17,156 +36,339 @@ export default async function Dashboard({
|
||||
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
|
||||
}
|
||||
return (
|
||||
<section style={{ maxWidth: 480 }}>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 12 }}>Sign in to {slug}</h1>
|
||||
<form action={login}>
|
||||
<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>
|
||||
</section>
|
||||
<div className="content-inner">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div className="page-title">Sign in to {tenant.name}</div>
|
||||
<div className="page-sub">
|
||||
Authenticate via Keycloak to view the {tenant.short} control plane.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Panel bracket>
|
||||
<form action={login}>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Sign in with Keycloak
|
||||
</button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: `/${slug}/dashboard` });
|
||||
}
|
||||
|
||||
const tenant = await fetchTenantBySlug(slug);
|
||||
const products = session.products ?? [];
|
||||
const trialDaysLeft = computeTrialDaysLeft(tenant?.trial_ends_at);
|
||||
const m = tenant.metrics;
|
||||
const userOnly =
|
||||
(session.org_roles ?? []).every((r) => r === "USER") || session.org_roles?.length === 0;
|
||||
const f30 = tenant.series.findings30;
|
||||
const lastWindow = f30.slice(-14);
|
||||
const findingsDelta = m.findingsDelta;
|
||||
const ctrlPct = Math.round((m.controlsPassing / m.controlsTotal) * 100);
|
||||
const sparkEvidence = tenant.series.evidence30;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{tenant?.status === "trial" && tenant.trial_ends_at && (
|
||||
<TrialBanner
|
||||
endsAt={tenant.trial_ends_at}
|
||||
slug={slug}
|
||||
daysLeft={trialDaysLeft}
|
||||
/>
|
||||
)}
|
||||
<div className="content-inner">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<div className="page-title">
|
||||
{userOnly ? "Workspace" : "Overview"}
|
||||
</div>
|
||||
<div className="page-sub">
|
||||
{tenant.name} ·{" "}
|
||||
<span className="mono">
|
||||
{new Date().toISOString().slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ph-actions">
|
||||
<button type="button" className="btn">
|
||||
<Download size={14} /> Export
|
||||
</button>
|
||||
<WriteGuarded status={tenant.status}>
|
||||
<button type="button" className="btn btn-primary">
|
||||
<Play size={14} /> Run scan
|
||||
</button>
|
||||
</WriteGuarded>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{!userOnly ? (
|
||||
<div className="kpi-rail bracket">
|
||||
<div className="kpi">
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<span className="label-micro">Open findings</span>
|
||||
</div>
|
||||
<div className="kpi-top">
|
||||
<span className="kpi-val">{m.openFindings}</span>
|
||||
<span
|
||||
className={`kpi-delta ${findingsDelta > 0 ? "delta-up" : "delta-down"}`}
|
||||
>
|
||||
{findingsDelta > 0 ? "+" : ""}
|
||||
{findingsDelta} · 7d
|
||||
</span>
|
||||
</div>
|
||||
<div className="kpi-viz">
|
||||
<Sparkbars data={lastWindow} width={132} height={26} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<div className="kpi">
|
||||
<span className="label-micro">Critical open</span>
|
||||
<div className="kpi-top">
|
||||
<span className="kpi-val" style={{ color: "var(--sev-critical)" }}>
|
||||
{m.critical}
|
||||
</span>
|
||||
<span className="kpi-delta muted">of {m.openFindings}</span>
|
||||
</div>
|
||||
<div className="kpi-viz" style={{ marginTop: 7 }}>
|
||||
<StackBar counts={m.severity} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kpi">
|
||||
<span className="label-micro">Controls passing</span>
|
||||
<div className="kpi-ring">
|
||||
<Ring value={m.controlsPassing} total={m.controlsTotal} size={48} stroke={5} />
|
||||
<div className="col" style={{ gap: 2 }}>
|
||||
<span
|
||||
className="kpi-val"
|
||||
style={{ fontSize: 22 }}
|
||||
>
|
||||
{ctrlPct}
|
||||
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>%</span>
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: "var(--ink-3)" }}>
|
||||
{m.controlsPassing} / {m.controlsTotal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kpi">
|
||||
<span className="label-micro">Evidence</span>
|
||||
<div className="kpi-top">
|
||||
<span className="kpi-val">{m.evidence}</span>
|
||||
<span className="kpi-delta delta-down">+{m.resolved7} · 7d</span>
|
||||
</div>
|
||||
<div className="kpi-viz">
|
||||
<Sparkline
|
||||
data={sparkEvidence}
|
||||
width={132}
|
||||
height={26}
|
||||
stroke="var(--ok)"
|
||||
fill="color-mix(in oklch, var(--ok) 16%, transparent)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kpi">
|
||||
<span className="label-micro">Last scan</span>
|
||||
<div className="kpi-top">
|
||||
<span className="kpi-val" style={{ fontSize: 20 }}>
|
||||
{m.lastScan}
|
||||
</span>
|
||||
</div>
|
||||
<div className="kpi-viz">
|
||||
<Sparkbars
|
||||
data={tenant.series.controls30.slice(-14)}
|
||||
width={132}
|
||||
height={26}
|
||||
color="var(--ink-3)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid g-12" style={{ marginTop: 14 }}>
|
||||
{!userOnly ? (
|
||||
<div className="span-8 col" style={{ gap: 12 }}>
|
||||
<Panel
|
||||
title="Findings · 30-day flow"
|
||||
tail={
|
||||
<span className="mono muted" style={{ fontSize: 10.5 }}>
|
||||
{f30.length}d window
|
||||
</span>
|
||||
}
|
||||
pad={false}
|
||||
bracket
|
||||
>
|
||||
<div style={{ padding: 14 }}>
|
||||
<Sparkline data={f30} width={680} height={90} />
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<div style={{ padding: 14 }}>
|
||||
<StackBar counts={m.severity} height={9} />
|
||||
<div className="sevlegend">
|
||||
{(["critical", "high", "medium", "low"] as Severity[]).map((k) => (
|
||||
<span key={k} className="sl">
|
||||
<span className="sw" style={{ background: `var(--sev-${k})` }} />
|
||||
{SEV_LABEL[k]}
|
||||
<span className="slc">{m.severity[k]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Top open findings"
|
||||
tail={
|
||||
<Link
|
||||
href={`/${slug}/products`}
|
||||
className="row mono"
|
||||
style={{ fontSize: 11, color: "var(--ink-3)" }}
|
||||
>
|
||||
View all <ArrowRight size={12} />
|
||||
</Link>
|
||||
}
|
||||
pad={false}
|
||||
>
|
||||
<table className="ltable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sev</th>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Control</th>
|
||||
<th className="r">Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenant.findings
|
||||
.filter((f) => f.status === "open")
|
||||
.slice(0, 5)
|
||||
.map((f) => (
|
||||
<tr key={f.id} className="clickable">
|
||||
<td>
|
||||
<Sev level={f.severity} />
|
||||
</td>
|
||||
<td className="t-id">{f.id}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: 380,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.title}
|
||||
</td>
|
||||
<td className="mono t-dim">{f.control}</td>
|
||||
<td className="r mono">{f.ageDays}d</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={userOnly ? "span-12 col" : "span-4 col"} style={{ gap: 12 }}>
|
||||
<Panel title="Product posture" pad={false}>
|
||||
{tenant.products
|
||||
.filter(
|
||||
(p) => tenant.entitled.includes(p.id) || tenant.trialing.includes(p.id),
|
||||
)
|
||||
.map((p) => {
|
||||
const arr = tenant.series.prodSeries[p.id] ?? [];
|
||||
const open = tenant.findings.filter(
|
||||
(f) => f.product === p.id && f.status === "open",
|
||||
).length;
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/${slug}/products?p=${encodeURIComponent(p.slug)}`}
|
||||
className="posture"
|
||||
>
|
||||
<Monogram text={p.mono} size={28} />
|
||||
<div className="col" style={{ minWidth: 0, flex: 1 }}>
|
||||
<span className="pname">{p.name}</span>
|
||||
<span className="pslug">{p.slug}</span>
|
||||
</div>
|
||||
<div className="pspark">
|
||||
<Sparkline data={arr} width={120} height={28} />
|
||||
</div>
|
||||
<div className="col" style={{ minWidth: 38 }}>
|
||||
<span className="pnum">{open}</span>
|
||||
<span className="pnl">open</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Scan & activity"
|
||||
tail={
|
||||
<span className="muted mono" style={{ fontSize: 10 }}>
|
||||
5 weeks
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||
<Heatmap data={tenant.series.heatmap} cell={20} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
border: "1px solid #eaeaea",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
marginTop: 10,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<HeatLegend />
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Pure compute, lives outside any render path so react-hooks/purity is satisfied.
|
||||
function computeTrialDaysLeft(endsAt: string | null | undefined): number {
|
||||
if (!endsAt) return 0;
|
||||
const ms = new Date(endsAt).getTime() - Date.now();
|
||||
return Math.max(0, Math.ceil(ms / (24 * 3600 * 1000)));
|
||||
}
|
||||
|
||||
function TrialBanner({
|
||||
endsAt,
|
||||
slug,
|
||||
daysLeft,
|
||||
}: {
|
||||
endsAt: string;
|
||||
slug: string;
|
||||
daysLeft: number;
|
||||
}) {
|
||||
const ends = new Date(endsAt);
|
||||
const urgent = daysLeft <= 3;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
borderRadius: 8,
|
||||
background: urgent ? "#fdecea" : "#fff7e0",
|
||||
color: urgent ? "#a82626" : "#7a5a00",
|
||||
border: `1px solid ${urgent ? "#e8a5a5" : "#e6d28a"}`,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Trial — <strong>{daysLeft}</strong> day{daysLeft === 1 ? "" : "s"} left
|
||||
{" "}(ends {ends.toLocaleDateString()}).
|
||||
</span>
|
||||
<a
|
||||
href={`/${slug}/billing`}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: urgent ? "#a82626" : "#7a5a00",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
Upgrade →
|
||||
</a>
|
||||
<Panel title="Recent activity" pad={false}>
|
||||
<div className="feed">
|
||||
{tenant.activity.slice(0, 5).map((a, i) => {
|
||||
const prod = productById(a.product);
|
||||
return (
|
||||
<div key={i} className="feed-row">
|
||||
<span className="feed-time">{a.when}</span>
|
||||
<div className="feed-body">
|
||||
<span className="fa">{a.actor}</span>{" "}
|
||||
<span className="ft">{a.verb}</span>{" "}
|
||||
<span className="mono ft">{a.target}</span>
|
||||
</div>
|
||||
<span className="feed-prod">
|
||||
{prod?.mono ?? a.product.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wraps any write CTA in the frozen-write hovercard guard. On `frozen`
|
||||
// tenants the button is disabled and a tooltip explains the 402.
|
||||
function WriteGuarded({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (status !== "frozen") return <>{children}</>;
|
||||
return (
|
||||
<span className="guard">
|
||||
{children}
|
||||
<span className="hovercard" role="tooltip">
|
||||
<strong style={{ display: "block", marginBottom: 4 }}>
|
||||
<ShieldAlert size={12} style={{ verticalAlign: -1, marginRight: 4 }} />
|
||||
Tenant is read-only
|
||||
</strong>
|
||||
<span className="hc-code">HTTP 402 · payment required</span>
|
||||
<br />
|
||||
<a className="hc-link" href="#">
|
||||
Re-activate to continue →
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user