Files
portal/src/lib/portal-data.ts
T
Sharang ParnerkarandClaude Opus 4.7 f3c95123fa 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>
2026-06-04 13:29:29 +02:00

97 lines
2.8 KiB
TypeScript

// Bridges the real tenant-registry contract with the rich design-fixture
// shape that the new portal screens render against.
//
// During M10.2 dev there's no requirement that tenant-registry be up — the
// portal still has to render the design end-to-end. So this loader resolves
// against the fixtures first (which carry the design fields: mono, seats,
// plan label, lifecycle reasons, generated series, etc.) and falls back to
// what tenant-registry returns when a slug isn't in the fixture set.
//
// Once the registry is enriched to carry the design fields end-to-end this
// module collapses into a thin pass-through.
import { tenantBySlug, type TenantRecord } from "@/lib/fixtures";
import { fetchTenantBySlug, type Tenant } from "@/lib/tenant-registry";
export type PortalTenant = TenantRecord;
export async function loadTenantForShell(slug: string): Promise<PortalTenant | null> {
const fx = tenantBySlug(slug);
if (fx) return fx;
// Slug isn't one of the 5 fixture tenants — try the real registry. If
// the registry isn't reachable we let the error bubble to the layout's
// notFound() path.
let live: Tenant | null = null;
try {
live = await fetchTenantBySlug(slug);
} catch {
return null;
}
if (!live) return null;
// Minimal shim so the shell can render. Design-rich fields fall back to
// placeholders that won't blow up the layout.
return {
id: live.id,
domain: `${slug}.breakpilot.eu`,
name: live.name,
short: live.name,
mono: live.name.slice(0, 2).toUpperCase(),
status: live.status,
legalType: "—",
city: "—",
country: "—",
vat: "—",
plan: live.plan,
planCode: live.plan,
seats: { used: 0, total: 0 },
monthly: 0,
contact: "—",
contactEmail: "—",
renewal: "—",
since: live.created_at?.slice(0, 10) ?? "—",
entitled: [],
trialing: [],
trialEnds: live.trial_ends_at ?? undefined,
seed: 0,
findingCount: 0,
products: [],
findings: [],
activity: [],
audit: [],
invoices: [],
team: [],
series: {
findings30: [],
evidence30: [],
controls30: [],
heatmap: [],
prodSeries: {},
},
metrics: {
openFindings: 0,
critical: 0,
lastScan: "—",
lastScanDate: "—",
evidence: 0,
controlsPassing: 0,
controlsTotal: 240,
severity: { critical: 0, high: 0, medium: 0, low: 0 },
resolved7: 0,
findingsDelta: 0,
},
};
}
// Returns the trial-days-left if any.
export function trialDaysLeft(t: PortalTenant, nowMs: number = Date.now()): number {
if (t.status !== "trial") return 0;
if (typeof t.trialDaysLeft === "number") return t.trialDaysLeft;
if (t.trialEnds) {
const ms = new Date(t.trialEnds).getTime() - nowMs;
return Math.max(0, Math.ceil(ms / (24 * 3600 * 1000)));
}
return 0;
}