f3c95123fa
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>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
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,
|
|
}: {
|
|
params: Promise<{ slug: string }>;
|
|
}) {
|
|
const { slug } = await params;
|
|
const session = await getPortalSession();
|
|
const tenant = await loadTenantForShell(slug);
|
|
if (!tenant) return null;
|
|
|
|
if (!session) {
|
|
async function login() {
|
|
"use server";
|
|
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
|
|
}
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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>
|
|
|
|
{!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>
|
|
|
|
<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={{
|
|
marginTop: 10,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<HeatLegend />
|
|
</div>
|
|
</Panel>
|
|
|
|
<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>
|
|
);
|
|
}
|