2 Commits
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.7 91a655b6df feat(portal): M10.2 — restyle Products + Org + Team + Billing + Audit + SSO
ci / image (pull_request) Has been skipped
ci / test (pull_request) Failing after 5m25s
ci / shared (pull_request) Successful in 27s
ci / e2e (pull_request) Has been skipped
Six existing customer-area shells under [slug]/* rebuilt against the
handoff design (sections §2/§4/§5/§6/§7/§8). Every screen reuses the
new Panel / Monogram / Sev primitives and the ledger-table token system
so the visual contract stays single-source-of-truth in globals.css.

* `[slug]/settings` (Organization, IT_ADMIN) — legal entity dl, primary
  contact card, plan & seats meter, products subscribed kv-list
  (ENTITLED green dot / TRIALING amber dot).
* `[slug]/settings/users` (Team, IT_ADMIN) — bracketed member ledger
  with role chips, last-active mono dim, active/invited dot status.
  Invite affordance present, modal wiring deferred.
* `[slug]/billing` (Billing, CXO + FINANCE + IT_ADMIN) — current plan
  card with monthly net + 19% VAT, seats + evidence-storage meters,
  payment method block that swaps to "Payment failed → Re-activate"
  when tenant.status is frozen, full invoices ledger with paid/due dot.
* `[slug]/audit` (Audit log, LEGAL + IT_ADMIN) — filter bar (search +
  event-type chip toggles + product select), ledger table with denied
  red dot, footer count + retention note.
* `[slug]/settings/integrations` (SSO, IT_ADMIN) — read-only OIDC
  summary pulling from KEYCLOAK_ISSUER / KEYCLOAK_CLIENT_ID, IdP-group→
  role mapping table.
* `[slug]/products` (Products index, USER+) — 2x2 product grid with
  live cards (entitled + trialing chips) and "Coming soon" dashed
  placeholders, plus a cross-product findings table with filter chips.

Plus a new `NotAllowed` 403 surface in the same ledger language that
replaces the inline "NotAuthorized" message used by the old shells, so
forbidden routes still look like the rest of the portal.

Every page goes through `getPortalSession()` so `BP_DEV_FIXTURE` still
swaps between admin / user / trial / frozen / archived without
Keycloak. Every screen returns 200 against
`BP_DEV_FIXTURE=admin-acme pnpm dev`.

Still to come on this branch:
* Workflows editor (palette + canvas + inspector + drag-wiring)
* ⌘K command palette + toasts
* Product launch detail (per-product page)
* Login redesign (mock SSO picker + violet gradient panel)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:35:25 +02:00
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
32 changed files with 4196 additions and 688 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10 -1
View File
@@ -19,19 +19,28 @@
"e2e:install": "playwright install --with-deps chromium" "e2e:install": "playwright install --with-deps chromium"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.17.0",
"next": "16.2.6", "next": "16.2.6",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"react": "19.0.0", "react": "19.0.0",
"react-dom": "19.0.0" "react-dom": "19.0.0",
"tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4.3.0",
"@types/node": "20.16.10", "@types/node": "20.16.10",
"@types/react": "19.0.1", "@types/react": "19.0.1",
"@types/react-dom": "19.0.1", "@types/react-dom": "19.0.1",
"@vitest/coverage-v8": "2.1.8", "@vitest/coverage-v8": "2.1.8",
"eslint": "9.15.0", "eslint": "9.15.0",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"msw": "^2.14.6",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "5.7.2", "typescript": "5.7.2",
"vitest": "2.1.8" "vitest": "2.1.8"
} }
+767 -68
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+72 -178
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 { 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({ export default async function AuditPage({
params, params,
searchParams, searchParams,
}: { }: {
params: Promise<{ slug: string }>; 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 { slug } = await params;
const q = await searchParams; const sp = await searchParams;
const session = (await auth()) as SessionWithExtras | null; const session = await getPortalSession();
if (!canSee(session, "audit")) return <NotAuthorized />; if (!canSee(session, "audit")) return <NotAllowed need="LEGAL / IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const tenant = await fetchTenantBySlug(slug); const q = sp.q?.toLowerCase() ?? "";
if (!tenant) redirect(`/${slug}/dashboard`); const type = sp.type ?? "all";
const product = sp.product ?? "all";
const cursor = q.cursor ? Number(q.cursor) : undefined; const rows = t.audit.filter((r) => {
const page = await fetchAudit({ if (q && !`${r.event} ${r.actor} ${r.product}`.toLowerCase().includes(q)) return false;
tenant_id: tenant.id, if (type !== "all" && !r.event.startsWith(type)) return false;
action: q.action || undefined, if (product !== "all" && r.product !== product) return false;
actor_id: q.actor_id || undefined, return true;
limit: PAGE_SIZE,
cursor: cursor && !Number.isNaN(cursor) ? cursor : undefined,
}); });
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 ( return (
<section> <div className="content-inner">
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Audit log</h1> <div className="page-head">
<p style={{ color: "#444", marginBottom: 16 }}> <div>
Every state-changing action emitted by the portal and the products.{" "} <div className="page-title">Audit log</div>
<a <div className="page-sub">
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PRODUCT_INTEGRATION_SPEC.md" <span className="mono">{rows.length}</span> of <span className="mono">{t.audit.length}</span>{" "}
style={{ color: "#0070f3" }} events · retention 365 days · hash-chained
> </div>
Retraced-shape schema </div>
</a>{" "} <div className="ph-actions">
CSV / PDF export lands in M10.2. <button type="button" className="btn">Export (CSV)</button>
</p> </div>
</div>
<Filters slug={slug} active={{ action: q.action, actor_id: q.actor_id }} /> <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>
{page.items.length === 0 ? ( <table className="ltable">
<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> <thead>
<tr style={{ textAlign: "left", borderBottom: "1px solid #eaeaea" }}> <tr>
<th style={th}>When</th> <th>When</th>
<th style={th}>Action</th> <th>Event</th>
<th style={th}>Actor</th> <th>Actor</th>
<th style={th}>Target</th> <th>Product</th>
<th style={th}>Product</th> <th>Source IP</th>
<th style={th}>Meta</th> <th>Result</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{page.items.map((ev) => ( {rows.slice(0, 50).map((r, i) => (
<tr key={ev.id} style={{ borderBottom: "1px solid #f0f0f0" }}> <tr key={i}>
<td style={td} title={formatDateTime(ev.created_at)}> <td className="mono t-dim" style={{ whiteSpace: "nowrap" }}>{r.date} {r.time}</td>
{formatRelative(ev.created_at)} <td className="mono" style={{ fontSize: 11.5 }}>{r.event}</td>
</td> <td>{r.actor}</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace" }}> <td className="mono t-dim">{r.product}</td>
{ev.action} <td className="mono t-dim">{r.ip}</td>
</td> <td>
<td style={td}> <span className="row" style={{ gap: 6, fontSize: 12 }}>
{ev.actor_name || ev.actor_id || ( <span className={`dot ${r.result === "denied" ? "danger" : "ok"}`} />
<em style={{ color: "#999" }}>system</em> {r.result === "denied" ? "DENIED" : "OK"}
)} </span>
</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> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</Panel>
</div> </div>
)}
<div style={{ marginTop: 16, display: "flex", justifyContent: "space-between" }}>
<div>
{resetHref && (
<Link href={resetHref as `/${string}`} style={btnLink}>
Clear filters
</Link>
)}
</div>
<div>
{nextHref && (
<Link href={nextHref as `/${string}`} style={btnLink}>
Next page
</Link>
)}
</div>
</div>
</section>
); );
} }
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 { 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,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null; const session = await getPortalSession();
if (!canSee(session, "billing")) return <NotAuthorized />; 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 ( return (
<ShellEmpty <div className="content-inner" style={{ maxWidth: 1080 }}>
title="Billing" <div className="page-head">
description="Plan, seats, invoices. Polar Checkout opens here for plan changes." <div>
milestone="M8.3" <div className="page-title">Billing</div>
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." <div className="page-sub">Plan, usage, payment method and invoice history</div>
cta={ </div>
<Link <div className="ph-actions">
href={`/${slug}/catalog`} <button type="button" className="btn">Download all (PDF)</button>
style={{ color: "#0070f3", fontSize: 14, textDecoration: "underline" }} </div>
> </div>
Browse the catalog
</Link> <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>
); );
} }
+353 -151
View File
@@ -1,7 +1,24 @@
import { auth, signIn, signOut } from "@/auth"; import Link from "next/link";
import { ShellEmpty } from "@/components/ShellEmpty"; import { ArrowRight, Download, Play, ShieldAlert } from "lucide-react";
import type { SessionWithExtras } from "@/lib/session"; import { signIn } from "@/auth";
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 { 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({ export default async function Dashboard({
params, params,
@@ -9,7 +26,9 @@ export default async function Dashboard({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; 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) { if (!session) {
async function login() { async function login() {
@@ -17,156 +36,339 @@ export default async function Dashboard({
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` }); await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
} }
return ( return (
<section style={{ maxWidth: 480 }}> <div className="content-inner">
<h1 style={{ fontSize: 28, marginBottom: 12 }}>Sign in to {slug}</h1> <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}> <form action={login}>
<button <button type="submit" className="btn btn-primary">
type="submit"
style={{
padding: "10px 16px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Sign in with Keycloak Sign in with Keycloak
</button> </button>
</form> </form>
</section> </Panel>
);
}
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);
return (
<section>
{tenant?.status === "trial" && tenant.trial_ends_at && (
<TrialBanner
endsAt={tenant.trial_ends_at}
slug={slug}
daysLeft={trialDaysLeft}
/>
)}
<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>
<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}
style={{
padding: 16,
border: "1px solid #eaeaea",
borderRadius: 8,
background: "white",
}}
>
<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>
)}
<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>
</div> </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>
);
}
+60 -25
View File
@@ -1,9 +1,11 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { auth } from "@/auth"; import { getPortalSession } from "@/lib/get-session";
import { Nav } from "@/components/Nav"; import { loadTenantForShell } from "@/lib/portal-data";
import type { SessionWithExtras } from "@/lib/session"; import { Lifeline } from "@/components/portal/Lifeline";
import { fetchTenantBySlug } from "@/lib/tenant-registry"; import { NavRail } from "@/components/portal/NavRail";
import { Topbar } from "@/components/portal/Topbar";
import { ArchivedLockout } from "@/components/portal/ArchivedLockout";
export default async function TenantLayout({ export default async function TenantLayout({
children, children,
@@ -13,10 +15,10 @@ export default async function TenantLayout({
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const tenant = await fetchTenantBySlug(slug); const tenant = await loadTenantForShell(slug);
if (!tenant) notFound(); if (!tenant) notFound();
const session = (await auth()) as SessionWithExtras | null; const session = await getPortalSession();
// Tenant mismatch guard — a JWT scoped to tenant A must not be allowed // Tenant mismatch guard — a JWT scoped to tenant A must not be allowed
// to view tenant B. If the slug in the path doesn't match the session // to view tenant B. If the slug in the path doesn't match the session
@@ -25,27 +27,60 @@ export default async function TenantLayout({
redirect(`/${session.tenant_slug}/dashboard`); redirect(`/${session.tenant_slug}/dashboard`);
} }
// Archived tenants get a full-page 410 — no shell, no nav, no chrome.
if (tenant.status === "archived") {
return <ArchivedLockout tenant={tenant} />;
}
// Unauthenticated visitors land on the existing in-page sign-in (each
// route handles its own zero-session affordance).
if (!session) {
return ( return (
<div style={{ display: "flex", minHeight: "100vh" }}> <div className="app">
{session ? <Nav slug={slug} session={session} /> : null} <div className="app-body">
<div style={{ flex: 1 }}> <main className="main">
<header <div className="content">
style={{ <div className="content-inner">{children}</div>
padding: "12px 24px", </div>
borderBottom: "1px solid #eaeaea", </main>
background: "white",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<strong>{tenant.name}</strong>
<span style={{ fontSize: 12, color: "#666" }}>
{tenant.plan} · {tenant.status}
</span>
</header>
<main style={{ padding: 24 }}>{children}</main>
</div> </div>
</div> </div>
); );
} }
return (
<div className="app">
<Lifeline
tenant={{
status: tenant.status,
slug,
plan: tenant.plan,
seats: tenant.seats,
trialDaysLeft: tenant.trialDaysLeft,
trialEnds: tenant.trialEnds,
frozenReason: tenant.frozenReason,
}}
/>
<div className="app-body">
<NavRail
slug={slug}
tenant={{
name: tenant.name,
short: tenant.short,
mono: tenant.mono,
plan: tenant.plan,
status: tenant.status,
}}
session={session}
/>
<main className="main">
<Topbar crumbs={[{ label: tenant.short }]} />
<div className="content">{children}</div>
</main>
</div>
{tenant.status === "demo" ? (
<div className="watermark" aria-hidden />
) : null}
</div>
);
}
+139 -98
View File
@@ -1,118 +1,159 @@
import { redirect } from "next/navigation"; import Link from "next/link";
import { auth } from "@/auth"; import { ArrowRight } from "lucide-react";
import { NotAuthorized } from "@/components/ShellEmpty";
import { formatDateTime, formatRelative } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session"; import { canSee } from "@/lib/session";
import { import { getPortalSession } from "@/lib/get-session";
fetchCatalog, import { loadTenantForShell } from "@/lib/portal-data";
fetchEntitlements, import { Panel } from "@/components/portal/Panel";
fetchTenantBySlug, import { Monogram } from "@/components/portal/Monogram";
type CatalogEntry, import { Sev } from "@/components/portal/Sev";
type Entitlement, import { NotAllowed } from "@/components/portal/NotAllowed";
} from "@/lib/tenant-registry";
export default async function ProductsPage({ export default async function ProductsPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
searchParams: Promise<{ p?: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null; const sp = await searchParams;
if (!canSee(session, "products")) return <NotAuthorized />; 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); const productFilter = sp.p ?? "all";
if (!tenant) redirect(`/${slug}/dashboard`); const findings = t.findings.filter((f) => productFilter === "all" || f.product === productFilter);
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);
return ( return (
<section> <div className="content-inner">
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Products</h1> <div className="page-head">
<p style={{ color: "#444", marginBottom: 24 }}> <div>
Live entitlements for <strong>{tenant.name}</strong>. Open a product to <div className="page-title">Products</div>
use its web component (M6.x / M7.x). <div className="page-sub">
</p> {t.entitled.length} entitled · click a product to open its launch screen
{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> </div>
) : ( </div>
<ul </div>
style={{
listStyle: "none", <div className="product-grid" style={{ marginBottom: 14 }}>
padding: 0, {t.products.map((p) => {
display: "grid", const isLive = p.status === "live";
gap: 12, const entitled = t.entitled.includes(p.id);
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", 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));
{active.map((e) => (
<ProductCard key={e.product} ent={e} catalog={byKey.get(e.product)} /> 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>
))} ))}
</ul> </div>
)} <div className="pcard-stats">
</section> <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>
function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) { <Panel
return ( title="Findings across products"
<li tail={
style={{ <span className="row" style={{ gap: 6 }}>
padding: 16, {["all", "compliance-scanner", "certifai"].map((o) => (
border: "1px solid #eaeaea", <Link
borderRadius: 8, key={o}
background: "white", href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
}} className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
> >
<div {o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }} </Link>
> ))}
<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)}
</span> </span>
)} }
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> </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>
); );
} }
+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 { 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 ( return (
<ShellEmpty <div className="content-inner" style={{ maxWidth: 1080 }}>
title="Integrations" <div className="page-head">
description="Webhooks, outbound integrations, and external IdP configuration." <div>
milestone="M15.2" <div className="page-title">SSO</div>
details="Webhook delivery (signed payloads, retry-with-backoff, dead-letter queue) lands alongside the headless-product API surface." <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 { 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,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { }) {
const { slug } = await params; const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null; const session = await getPortalSession();
if (!canSee(session, "settings")) return <NotAuthorized />; if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const tenant = await fetchTenantBySlug(slug); const subscribed = t.products.filter((p) => t.entitled.includes(p.id));
if (!tenant) { const trialing = t.products.filter((p) => t.trialing.includes(p.id));
return ( const seatsLeft = t.seats.total - t.seats.used;
<section> const pct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
<h1 style={{ fontSize: 28 }}>Settings</h1>
<p style={{ color: "#a82626" }}>Tenant not found.</p>
</section>
);
}
return ( return (
<section> <div className="content-inner" style={{ maxWidth: 1080 }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Settings</h1> <div className="page-head">
<p style={{ color: "#444", marginBottom: 24 }}> <div>
Tenant identity and lifecycle metadata. Editing these lands in the <div className="page-title">Organization</div>
M10.1 follow-up; for now contact <a href={`/${slug}/support`}>support</a>. <div className="page-sub">
</p> 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> <div className="grid g-12">
<Field label="Tenant ID" value={tenant.id} mono /> <div className="span-7 col" style={{ gap: 12 }}>
<Field label="Slug" value={tenant.slug} mono /> <Panel title="Legal entity" bracket>
<Field label="Name" value={tenant.name} /> <dl className="dl">
<Field label="Kind" value={tenant.kind} /> <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> <Panel title="Primary contact">
<Field label="Plan" value={tenant.plan} /> <div className="row" style={{ gap: 12 }}>
<Field label="Status" value={tenant.status} badge={statusColor(tenant.status)} /> <span className="avatar" style={{ width: 38, height: 38, fontSize: 13 }}>
{tenant.trial_ends_at && ( {t.contact.split(" ").map((s) => s[0]).join("")}
<Field label="Trial ends at" value={formatDateTime(tenant.trial_ends_at)} mono />
)}
<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 />
<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> </span>
) : ( <div>
value <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>
<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>
<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>
<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> </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 { 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 ( return (
<ShellEmpty <div className="content-inner">
title="Users" <div className="page-head">
description="Invite teammates as IT_ADMIN, CXO, FINANCE, LEGAL, or USER." <div>
milestone="M10.1 follow-up" <div className="page-title">Team</div>
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="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>
); );
} }
+693
View File
@@ -0,0 +1,693 @@
@import "tailwindcss";
/* ============================================================
BREAKPILOT — "The portal is a ledger"
Light paper-white ledger system. Hairlines, corner ticks,
monospace machine values, restrained functional status.
============================================================ */
:root {
/* paper + ink — brand lavender-white / purple-ink */
--paper: oklch(0.976 0.008 300);
--paper-2: oklch(0.958 0.012 300); /* recessed wells */
--surface: oklch(0.995 0.004 300); /* panels */
--ink: oklch(0.24 0.035 295); /* primary text */
--ink-2: oklch(0.45 0.026 295); /* secondary */
--ink-3: oklch(0.6 0.02 297); /* mono metadata / muted */
--ink-4: oklch(0.72 0.015 300); /* faintest */
/* hairlines (purple-tinted) */
--rule: oklch(0.905 0.011 300); /* default hairline */
--rule-2: oklch(0.85 0.014 300); /* stronger */
--rule-3: oklch(0.78 0.018 300); /* heaviest */
/* the single interactive accent — brand violet */
--accent: oklch(0.52 0.23 293);
--accent-2: oklch(0.52 0.23 293 / 0.10);
--accent-ring:oklch(0.52 0.23 293 / 0.32);
/* functional status — low chroma, muted */
--ok: oklch(0.55 0.085 155);
--ok-bg: oklch(0.55 0.085 155 / 0.10);
--warn: oklch(0.62 0.105 70);
--warn-bg:oklch(0.62 0.105 70 / 0.12);
--danger:oklch(0.53 0.15 27);
--danger-bg:oklch(0.53 0.15 27 / 0.10);
--info: oklch(0.5 0.07 240);
/* severity ramp (findings) */
--sev-critical: oklch(0.5 0.16 25);
--sev-high: oklch(0.6 0.13 45);
--sev-medium: oklch(0.64 0.1 75);
--sev-low: oklch(0.6 0.02 260);
--font-sans: var(--font-geist-sans), 'Geist', system-ui, -apple-system, sans-serif;
--font-mono: var(--font-geist-mono), 'Geist Mono', ui-monospace, 'SF Mono', monospace;
--font-serif: var(--font-geist-sans), 'Geist', system-ui, sans-serif;
--rail-w: 232px;
--topbar-h: 48px;
--lifeline-h: 30px;
--shadow-pop: 0 1px 2px oklch(0.2 0.02 260 / 0.04),
0 8px 24px -8px oklch(0.2 0.02 260 / 0.18),
0 2px 8px -4px oklch(0.2 0.02 260 / 0.10);
}
/* ============ DARK LEDGER ============ */
:root[data-theme="dark"] {
--paper: oklch(0.195 0.028 292);
--paper-2: oklch(0.232 0.032 292);
--surface: oklch(0.238 0.032 292);
--ink: oklch(0.94 0.012 300);
--ink-2: oklch(0.75 0.016 300);
--ink-3: oklch(0.6 0.02 300);
--ink-4: oklch(0.48 0.022 300);
--rule: oklch(0.31 0.028 295);
--rule-2: oklch(0.37 0.03 295);
--rule-3: oklch(0.45 0.032 295);
--accent: oklch(0.7 0.2 293);
--accent-2: oklch(0.7 0.2 293 / 0.18);
--accent-ring:oklch(0.7 0.2 293 / 0.42);
--ok: oklch(0.7 0.13 158);
--ok-bg: oklch(0.7 0.13 158 / 0.14);
--warn: oklch(0.76 0.13 75);
--warn-bg:oklch(0.76 0.13 75 / 0.16);
--danger:oklch(0.66 0.17 26);
--danger-bg:oklch(0.66 0.17 26 / 0.15);
--info: oklch(0.68 0.1 240);
--sev-critical: oklch(0.66 0.18 26);
--sev-high: oklch(0.72 0.15 48);
--sev-medium: oklch(0.78 0.12 78);
--sev-low: oklch(0.6 0.02 264);
--shadow-pop: 0 1px 2px oklch(0 0 0 / 0.3),
0 12px 32px -10px oklch(0 0 0 / 0.6),
0 2px 8px -4px oklch(0 0 0 / 0.5);
}
:root[data-theme="dark"] .toast,
:root[data-theme="dark"] .hovercard { background: oklch(0.28 0.01 264); color: var(--ink); border: 1px solid var(--rule-3); }
:root[data-theme="dark"] .hovercard::after { border-top-color: oklch(0.28 0.01 264); }
:root[data-theme="dark"] .hovercard .hc-link { color: var(--accent); }
:root[data-theme="dark"] .brand-mark,
:root[data-theme="dark"] .monogram { background: var(--accent); color: oklch(0.16 0.01 264); }
:root[data-theme="dark"] .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
:root[data-theme="dark"] .btn-primary:hover { background: oklch(0.64 0.2 293); border-color: oklch(0.64 0.2 293); }
:root { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
html { transition: none; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: var(--font-sans);
background: var(--paper);
color: var(--ink);
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "cv05" 1, "ss01" 1;
}
#root { height: 100%; }
::selection { background: var(--accent-2); }
/* scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--rule-2); border: 3px solid var(--paper); border-radius: 8px; }
::-webkit-scrollbar-thumb:hover { background: var(--rule-3); }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- typographic primitives ---------- */
.mono { font-family: var(--font-mono); font-feature-settings: "zero" 1; }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-feature-settings: "zero" 1; }
.eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--ink-3);
font-weight: 500;
}
.label-micro {
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
}
h1,h2,h3,h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); }
a { color: inherit; text-decoration: none; }
/* ---------- shell layout ---------- */
.app { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.app-body { display: flex; flex: 1; min-height: 0; }
/* lifecycle rail (top, full width) */
.lifeline {
height: var(--lifeline-h);
display: flex; align-items: center; gap: 12px;
padding: 0 16px;
border-bottom: 1px solid var(--rule);
background: var(--surface);
font-size: 12px;
flex-shrink: 0;
position: relative;
z-index: 30;
white-space: nowrap; overflow: hidden;
}
.lifeline > span { flex-shrink: 0; }
.lifeline > span.ll-spacer { flex-shrink: 1; }
.lifeline .ll-muted-detail { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.lifeline .btn { flex-shrink: 0; }
.lifeline .ll-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.lifeline.is-active { }
.lifeline.is-trial { background: var(--warn-bg); border-bottom-color: color-mix(in oklch, var(--warn) 30%, var(--rule)); }
.lifeline.is-frozen { background: var(--danger-bg); border-bottom-color: color-mix(in oklch, var(--danger) 32%, var(--rule)); }
.lifeline.is-demo { background: var(--paper-2); }
.lifeline .ll-spacer { flex: 1; }
.lifeline .ll-strong { font-weight: 600; }
.ll-count { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* nav rail */
.rail {
width: var(--rail-w);
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--rule);
display: flex; flex-direction: column;
overflow-y: auto;
}
.rail-head { padding: 14px 14px 10px; }
.brand { display: flex; align-items: center; gap: 9px; }
.brand-mark {
width: 26px; height: 26px; flex-shrink: 0;
background: var(--ink); color: var(--paper);
display: grid; place-items: center;
font-family: var(--font-mono); font-weight: 600; font-size: 13px;
border-radius: 5px;
}
.brand-name { font-weight: 600; font-size: 14px; letter-spacing: -0.02em; }
.brand-sub { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); letter-spacing: 0.06em; }
/* tenant switcher */
.tenant-switch {
margin: 4px 10px 8px;
border: 1px solid var(--rule-2);
border-radius: 7px;
padding: 8px 10px;
display: flex; align-items: center; gap: 9px;
cursor: pointer; background: var(--paper);
position: relative;
}
.tenant-switch:hover { border-color: var(--rule-3); background: var(--surface); }
.tenant-mono {
width: 24px; height: 24px; border-radius: 5px; flex-shrink: 0;
display: grid; place-items: center;
font-family: var(--font-mono); font-size: 11px; font-weight: 600;
background: var(--paper-2); border: 1px solid var(--rule-2); color: var(--ink-2);
}
.tenant-meta { min-width: 0; flex: 1; }
.tenant-meta .tn { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tenant-meta .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); display: flex; align-items: center; gap: 5px; }
/* nav groups */
.nav { padding: 6px 8px 16px; flex: 1; }
.nav-group { margin-top: 14px; }
.nav-group:first-child { margin-top: 4px; }
.nav-group-title { padding: 4px 8px 6px; }
.nav-item {
display: flex; align-items: center; gap: 9px;
padding: 6px 9px; margin: 1px 0;
border-radius: 6px; cursor: pointer;
color: var(--ink-2); font-size: 13px;
position: relative; user-select: none;
white-space: nowrap;
}
.nav-item:hover { background: var(--paper-2); color: var(--ink); }
.nav-item.active { background: var(--paper-2); color: var(--ink); font-weight: 600; }
.nav-item.active::before {
content: ""; position: absolute; left: -8px; top: 50%; transform: translateY(-50%);
width: 2px; height: 16px; background: var(--accent); border-radius: 2px;
}
.nav-item.disabled { color: var(--ink-4); cursor: not-allowed; }
.nav-item.disabled:hover { background: transparent; color: var(--ink-4); }
.nav-ico { width: 15px; display: grid; place-items: center; color: currentColor; opacity: 0.85; flex-shrink: 0; }
.nav-ico svg { width: 15px; height: 15px; display: block; }
.nav-tail { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
.nav-lock { margin-left: auto; opacity: 0.6; }
.rail-foot { border-top: 1px solid var(--rule); padding: 9px 12px; }
.user-chip { display: flex; align-items: center; gap: 9px; cursor: pointer; border-radius: 6px; padding: 4px; }
.user-chip:hover { background: var(--paper-2); }
.avatar {
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
display: grid; place-items: center; font-family: var(--font-mono);
font-size: 10px; font-weight: 600; background: var(--accent-2); color: var(--accent);
border: 1px solid var(--accent-ring);
}
.user-meta { min-width: 0; }
.user-meta .un { font-size: 12px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-meta .ue { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* topbar */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: var(--topbar-h); flex-shrink: 0;
border-bottom: 1px solid var(--rule);
display: flex; align-items: center; gap: 14px;
padding: 0 18px; background: var(--surface);
}
.crumbs { display: flex; align-items: center; gap: 8px; font-size: 13px; white-space: nowrap; }
.crumbs .c-sep { color: var(--ink-4); }
.crumbs .c-cur { font-weight: 600; }
.crumbs .c-prev { color: var(--ink-3); }
.topbar-spacer { flex: 1; }
.cmdk-btn {
display: flex; align-items: center; gap: 8px;
border: 1px solid var(--rule-2); border-radius: 7px;
padding: 5px 9px 5px 11px; color: var(--ink-3);
font-size: 12px; cursor: pointer; background: var(--paper);
}
.cmdk-btn:hover { border-color: var(--rule-3); color: var(--ink-2); }
.kbd {
font-family: var(--font-mono); font-size: 10px;
border: 1px solid var(--rule-2); border-bottom-width: 2px; border-radius: 4px;
padding: 1px 5px; color: var(--ink-3); background: var(--surface); line-height: 1.5;
}
/* content scroll area */
.content { flex: 1; overflow-y: auto; position: relative; }
.content-inner { max-width: 1240px; margin: 0 auto; padding: 22px 26px 64px; }
.page-head { display: flex; align-items: flex-end; gap: 16px; margin-bottom: 18px; }
.page-title { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; }
.page-sub { color: var(--ink-3); font-size: 12.5px; }
.page-head .ph-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
/* ---------- panel / corner-tick bracket ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 8px;
position: relative;
}
.panel-pad { padding: 16px; }
.panel-head {
display: flex; align-items: center; gap: 10px;
padding: 11px 14px; border-bottom: 1px solid var(--rule);
}
.panel-head .ph-title { font-size: 12px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; }
.panel-head .ph-tail { margin-left: auto; }
/* the distinctive corner ticks — applied to .bracket panels */
.bracket::before, .bracket::after {
content: ""; position: absolute; width: 7px; height: 7px; pointer-events: none;
border-color: var(--ink-3); opacity: 0.55;
}
.bracket::before { top: -1px; left: -1px; border-top: 1.5px solid; border-left: 1.5px solid; border-top-left-radius: 2px; }
.bracket::after { bottom: -1px; right: -1px; border-bottom: 1.5px solid; border-right: 1.5px solid; border-bottom-right-radius: 2px; }
/* ---------- grids ---------- */
.grid { display: grid; gap: 12px; }
.g-12 { grid-template-columns: repeat(12, 1fr); }
.metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; border: 1px solid var(--rule); border-radius: 8px; overflow: hidden; background: var(--surface); }
.metric {
padding: 13px 15px 14px; border-right: 1px solid var(--rule);
position: relative;
}
.metric:last-child { border-right: none; }
.metric .m-label { display: flex; align-items: center; gap: 6px; margin-bottom: 9px; }
.metric .m-value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 25px; font-weight: 500; letter-spacing: -0.02em; line-height: 1; }
.metric .m-value .m-unit { font-size: 13px; color: var(--ink-3); font-weight: 400; margin-left: 3px; }
.metric .m-foot { margin-top: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); display: flex; align-items: center; gap: 5px; }
.delta-up { color: var(--danger); } /* more findings = bad */
.delta-down { color: var(--ok); }
/* ---------- status dot + pill ---------- */
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); }
.dot.danger { background: var(--danger); } .dot.neutral { background: var(--ink-4); }
.dot.accent { background: var(--accent); }
.tag {
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em;
text-transform: uppercase; color: var(--ink-2);
padding: 1px 0; white-space: nowrap;
}
.sev { font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; letter-spacing: 0.02em; display: inline-flex; align-items: center; gap: 6px; }
.sev .bar { width: 3px; height: 11px; border-radius: 1px; display: inline-block; }
.sev.critical { color: var(--sev-critical); } .sev.critical .bar { background: var(--sev-critical); }
.sev.high { color: var(--sev-high); } .sev.high .bar { background: var(--sev-high); }
.sev.medium { color: var(--sev-medium); } .sev.medium .bar { background: var(--sev-medium); }
.sev.low { color: var(--sev-low); } .sev.low .bar { background: var(--sev-low); }
/* ---------- ledger table ---------- */
.ltable { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.ltable thead th {
text-align: left; font-family: var(--font-mono); font-weight: 500;
font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-3);
padding: 8px 14px; border-bottom: 1px solid var(--rule-2); white-space: nowrap;
position: sticky; top: 0; background: var(--surface); z-index: 1;
}
.ltable tbody td { padding: 8px 14px; border-bottom: 1px solid var(--rule); vertical-align: middle; }
.ltable tbody tr:last-child td { border-bottom: none; }
.ltable tbody tr { cursor: default; }
.ltable tbody tr.clickable { cursor: pointer; }
.ltable tbody tr.clickable:hover { background: var(--paper-2); }
.ltable td.r, .ltable th.r { text-align: right; }
.ltable td.mono, .ltable .mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.ltable .t-id { font-family: var(--font-mono); color: var(--ink-3); font-size: 11.5px; }
.ltable .t-dim { color: var(--ink-3); }
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: 7px;
font-family: var(--font-sans); font-size: 12.5px; font-weight: 500;
padding: 6px 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--rule-2); background: var(--surface); color: var(--ink);
white-space: nowrap; transition: background .1s, border-color .1s; flex-shrink: 0;
}
.btn:hover { background: var(--paper-2); border-color: var(--rule-3); }
.btn .btn-ico svg { width: 14px; height: 14px; display: block; }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: oklch(0.46 0.23 293); border-color: oklch(0.46 0.23 293); }
.btn-accent { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-accent:hover { background: oklch(0.42 0.105 256); }
.btn-sm { padding: 4px 9px; font-size: 12px; }
.btn-ghost { border-color: transparent; background: transparent; }
.btn-ghost:hover { background: var(--paper-2); border-color: var(--rule); }
.btn[disabled], .btn.is-disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; }
.btn-danger { color: var(--danger); border-color: color-mix(in oklch, var(--danger) 30%, var(--rule-2)); }
/* frozen write-guard wrapper */
.guard { position: relative; display: inline-flex; }
.guard .btn { opacity: 0.45; cursor: not-allowed; }
.guard .hovercard { display: none; }
.guard:hover .hovercard { display: block; }
.hovercard {
position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
width: 234px; background: var(--ink); color: var(--paper);
border-radius: 7px; padding: 10px 12px; font-size: 11.5px; line-height: 1.5;
box-shadow: var(--shadow-pop); z-index: 50;
}
.hovercard::after { content:""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--ink); }
.hovercard .hc-code { font-family: var(--font-mono); font-size: 10px; color: var(--warn); letter-spacing: 0.04em; }
.hovercard .hc-link { color: #fff; text-decoration: underline; cursor: pointer; }
/* ---------- inputs ---------- */
.input {
font-family: var(--font-sans); font-size: 13px;
padding: 7px 10px; border: 1px solid var(--rule-2); border-radius: 6px;
background: var(--surface); color: var(--ink); width: 100%; outline: none;
}
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
.input::placeholder { color: var(--ink-4); }
.field { display: flex; flex-direction: column; gap: 5px; }
.field > label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
/* ---------- meters ---------- */
.meter { height: 6px; border-radius: 3px; background: var(--paper-2); overflow: hidden; border: 1px solid var(--rule); }
.meter > span { display: block; height: 100%; background: var(--ink-2); }
.meter.warn > span { background: var(--warn); }
.meter.danger > span { background: var(--danger); }
/* ---- control-plane: KPI rail + viz ---- */
.kpi-rail { display: grid; grid-template-columns: repeat(5, 1fr); border: 1px solid var(--rule); border-radius: 8px; overflow: hidden; background: var(--surface); }
.kpi { padding: 12px 14px 13px; border-right: 1px solid var(--rule); display: flex; flex-direction: column; gap: 9px; min-width: 0; }
.kpi:last-child { border-right: none; }
.kpi-top { display: flex; align-items: baseline; gap: 7px; }
.kpi-val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 25px; font-weight: 500; letter-spacing: -0.02em; line-height: 1; }
.kpi-delta { font-family: var(--font-mono); font-size: 10.5px; white-space: nowrap; }
.kpi-viz { margin-top: auto; }
.kpi-ring { display: flex; align-items: center; gap: 11px; margin-top: auto; }
.posture { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-bottom: 1px solid var(--rule); cursor: pointer; }
.posture:last-child { border-bottom: none; }
.posture:hover { background: var(--paper-2); }
.posture .pname { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.posture .pslug { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.posture .pspark { flex: 1; min-width: 0; }
.posture .pnum { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 500; text-align: right; line-height: 1; }
.posture .pnl { font-family: var(--font-mono); font-size: 8.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); text-align: right; margin-top: 2px; }
.posture.frow { gap: 11px; }
.sevlegend { display: flex; gap: 13px; margin-top: 11px; flex-wrap: wrap; }
.sevlegend .sl { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 10.5px; }
.sevlegend .sl .sw { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.sevlegend .sl .slc { font-variant-numeric: tabular-nums; font-weight: 600; }
.heatlegend { display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 9px; color: var(--ink-3); }
.heatlegend .hc { width: 11px; height: 11px; border-radius: 2.5px; }
/* ---- theme toggle ---- */
.theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 7px; border: 1px solid var(--rule-2); background: var(--paper); color: var(--ink-2); cursor: pointer; flex-shrink: 0; }
.theme-toggle:hover { border-color: var(--rule-3); color: var(--ink); background: var(--paper-2); }
.theme-toggle svg { width: 15px; height: 15px; }
/* ---------- product cards ---------- */
.product-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.pcard {
background: var(--surface); border: 1px solid var(--rule); border-radius: 8px;
padding: 15px; cursor: pointer; position: relative;
display: flex; flex-direction: column; gap: 13px; min-height: 148px;
transition: border-color .12s, box-shadow .12s, transform .12s;
}
.pcard:hover { border-color: var(--rule-3); box-shadow: var(--shadow-pop); }
.pcard.soon { cursor: default; background: var(--paper-2); border-style: dashed; }
.pcard.soon:hover { box-shadow: none; border-color: var(--rule-2); }
.pcard-top { display: flex; align-items: flex-start; gap: 11px; }
.monogram {
width: 36px; height: 36px; border-radius: 7px; flex-shrink: 0;
display: grid; place-items: center; font-family: var(--font-mono);
font-weight: 600; font-size: 13px; letter-spacing: -0.02em;
background: var(--ink); color: var(--paper);
}
.monogram.soon { background: var(--paper); color: var(--ink-4); border: 1px dashed var(--rule-3); }
.pcard-top .pc-titles { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.pcard-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcard-slug { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcard-stats { display: flex; gap: 18px; margin-top: auto; }
.pstat .ps-v { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 16px; font-weight: 500; }
.pstat .ps-l { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.pcard-cta { position: absolute; top: 15px; right: 15px; color: var(--ink-3); }
.pcard:hover .pcard-cta { color: var(--accent); }
/* activity feed */
.feed { display: flex; flex-direction: column; }
.feed-row { display: flex; gap: 11px; padding: 9px 14px; border-bottom: 1px solid var(--rule); align-items: baseline; }
.feed-row:last-child { border-bottom: none; }
.feed-time { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); width: 64px; flex-shrink: 0; }
.feed-body { font-size: 12.5px; flex: 1; }
.feed-body .fa { font-weight: 600; }
.feed-body .ft { color: var(--ink-2); }
.feed-prod { font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); flex-shrink: 0; }
/* ---------- modal / palette ---------- */
.scrim { position: fixed; inset: 0; background: oklch(0.2 0.02 260 / 0.32); z-index: 100; display: grid; }
.scrim.center { place-items: center; }
.scrim.top { place-items: start center; padding-top: 12vh; }
.modal { background: var(--surface); border: 1px solid var(--rule-2); border-radius: 10px; box-shadow: var(--shadow-pop); width: 480px; max-width: calc(100vw - 32px); overflow: hidden; }
.modal-head { padding: 15px 18px; border-bottom: 1px solid var(--rule); display: flex; align-items: center; gap: 10px; }
.modal-title { font-size: 14px; font-weight: 600; white-space: nowrap; }
.modal-body { padding: 18px; }
.modal-foot { padding: 13px 18px; border-top: 1px solid var(--rule); display: flex; gap: 8px; justify-content: flex-end; background: var(--paper-2); }
/* command palette */
.cmdk { width: 560px; }
.cmdk-input-wrap { display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--rule); }
.cmdk-input { border: none; outline: none; background: transparent; font-size: 15px; flex: 1; color: var(--ink); font-family: var(--font-sans); }
.cmdk-input::placeholder { color: var(--ink-4); }
.cmdk-list { max-height: 52vh; overflow-y: auto; padding: 6px; }
.cmdk-section { padding: 9px 10px 4px; }
.cmdk-item { display: flex; align-items: center; gap: 11px; padding: 8px 10px; border-radius: 7px; cursor: pointer; }
.cmdk-item.sel { background: var(--paper-2); }
.cmdk-item .ci-mono { width: 22px; height: 22px; border-radius: 5px; display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; font-weight: 600; background: var(--paper-2); border: 1px solid var(--rule-2); color: var(--ink-2); flex-shrink: 0; }
.cmdk-item .ci-title { font-size: 13px; flex: 1; }
.cmdk-item .ci-kind { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-4); }
.cmdk-item.sel .ci-kind { color: var(--ink-3); }
.cmdk-foot { display: flex; gap: 14px; padding: 9px 16px; border-top: 1px solid var(--rule); font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
.cmdk-foot .cf { display: flex; align-items: center; gap: 5px; }
/* toast */
.toasts { position: fixed; bottom: 18px; right: 18px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast { background: var(--ink); color: var(--paper); border-radius: 8px; padding: 11px 14px; font-size: 12.5px; box-shadow: var(--shadow-pop); max-width: 340px; display: flex; gap: 10px; align-items: flex-start; animation: toastin .18s ease; }
.toast .t-code { font-family: var(--font-mono); font-size: 10px; color: var(--warn); }
@keyframes toastin { from { opacity: 0; transform: translateY(8px); } }
/* ---------- login ---------- */
.login { height: 100%; display: grid; grid-template-columns: 1.05fr 1fr; background: var(--paper); }
.login-left { padding: 48px 56px; display: flex; flex-direction: column; border-right: none; color: #fff; background: linear-gradient(165deg, oklch(0.57 0.2 288), oklch(0.42 0.2 297) 94%); }
.login-left .brand-name { color: #fff; }
.login-left .brand-name::after { color: #fff; }
.login-left .brand-sub { color: rgba(255,255,255,0.6); }
.login-left .brand-mark { background: #fff; color: var(--accent); }
.login-left .login-hero h1 { color: #fff; }
.login-left .login-hero p { color: rgba(255,255,255,0.85); }
.login-left .login-meta .lm { color: rgba(255,255,255,0.74); }
.login-left .login-meta .lm .lk { color: rgba(255,255,255,0.5); }
.login-left .eyebrow { color: rgba(255,255,255,0.58); }
.brand-name::after { content: "."; color: var(--accent); }
.login-right { padding: 48px 56px; display: flex; flex-direction: column; justify-content: center; background: var(--surface); }
.login-brand { display: flex; align-items: center; gap: 11px; margin-bottom: auto; }
.login-hero h1 { font-size: 30px; letter-spacing: -0.03em; line-height: 1.1; max-width: 440px; }
.login-hero p { color: var(--ink-2); max-width: 400px; margin-top: 14px; font-size: 14px; }
.login-meta { margin-top: 28px; display: flex; flex-direction: column; gap: 7px; }
.login-meta .lm { display: flex; align-items: baseline; gap: 10px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); }
.login-meta .lm .lk { color: var(--ink-4); width: 92px; }
.fixture-list { display: flex; flex-direction: column; gap: 8px; max-width: 460px; width: 100%; }
.fixture {
border: 1px solid var(--rule-2); border-radius: 8px; padding: 12px 14px;
display: flex; align-items: center; gap: 13px; cursor: pointer; background: var(--surface);
transition: border-color .1s, background .1s; text-align: left; width: 100%;
}
.fixture:hover { border-color: var(--accent); background: var(--paper-2); }
.fixture-mono { width: 34px; height: 34px; border-radius: 6px; display: grid; place-items: center; font-family: var(--font-mono); font-size: 12px; font-weight: 600; flex-shrink: 0; background: var(--paper-2); border: 1px solid var(--rule-2); }
.fixture-main { flex: 1; min-width: 0; }
.fixture-email { font-family: var(--font-mono); font-size: 12.5px; font-weight: 500; }
.fixture-show { font-size: 11px; color: var(--ink-3); margin-top: 1px; }
.fixture-state { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); flex-shrink: 0; }
.fixture-roles { display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap; }
.role-chip { font-family: var(--font-mono); font-size: 8.5px; letter-spacing: 0.06em; padding: 1px 5px; border: 1px solid var(--rule-2); border-radius: 3px; color: var(--ink-3); }
/* ---------- watermark (demo) ---------- */
.watermark { position: fixed; inset: 0; pointer-events: none; z-index: 80; overflow: hidden; opacity: 0.05; }
.watermark::before {
content: "SANDBOX · DEMO · SANDBOX · DEMO · SANDBOX · DEMO · SANDBOX · DEMO · ";
position: absolute; top: -20%; left: -20%; width: 160%; height: 160%;
font-family: var(--font-mono); font-size: 30px; font-weight: 700; letter-spacing: 0.1em;
line-height: 2.4; word-spacing: 6px; color: var(--ink);
transform: rotate(-24deg); white-space: pre-wrap;
}
/* ---------- archived lockout / error pages ---------- */
.lockout { height: 100%; display: grid; place-items: center; background: var(--paper); padding: 24px; }
.lockout-card { width: 560px; max-width: 100%; }
.lockout-rule { height: 1px; background: var(--rule-2); margin: 22px 0; }
.error-page { height: 100%; display: grid; place-items: center; }
.error-code { font-family: var(--font-mono); font-size: 84px; font-weight: 600; letter-spacing: -0.04em; line-height: 1; }
/* ---------- misc layout helpers ---------- */
.row { display: flex; align-items: center; gap: 10px; }
.col { display: flex; flex-direction: column; }
.between { justify-content: space-between; }
.wrap { flex-wrap: wrap; }
.spacer { flex: 1; }
.muted { color: var(--ink-3); }
.divider { height: 1px; background: var(--rule); }
.dl { display: grid; grid-template-columns: max-content 1fr; gap: 8px 18px; align-items: baseline; }
.dl dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); }
.dl dd { margin: 0; font-size: 13px; }
.dl dd.mono { font-family: var(--font-mono); font-size: 12px; }
.kv-list { display: flex; flex-direction: column; }
.kv { display: flex; justify-content: space-between; align-items: baseline; padding: 9px 0; border-bottom: 1px solid var(--rule); }
.kv:last-child { border-bottom: none; }
.kv .kvk { color: var(--ink-2); font-size: 12.5px; }
.kv .kvv { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12.5px; }
.span-7 { grid-column: span 7; } .span-5 { grid-column: span 5; }
.span-8 { grid-column: span 8; } .span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; } .span-12 { grid-column: span 12; }
@media (max-width: 920px) {
.login { grid-template-columns: 1fr; }
.login-left { display: none; }
.metric-row { grid-template-columns: repeat(2, 1fr); }
.metric:nth-child(2) { border-right: none; }
.kpi-rail { grid-template-columns: repeat(3, 1fr); }
.kpi:nth-child(3) { border-right: none; }
.product-grid { grid-template-columns: 1fr; }
}
/* ============ WORKFLOWS / FLOW EDITOR ============ */
.flow { height: 100%; display: flex; min-height: 0; background: var(--paper); overflow: hidden; }
/* palette */
.flow-palette { width: 234px; flex-shrink: 0; border-right: 1px solid var(--rule); background: var(--surface); display: flex; flex-direction: column; }
.flow-pal-head { padding: 12px 14px 10px; border-bottom: 1px solid var(--rule); display: flex; flex-direction: column; gap: 3px; }
.flow-pal-body { flex: 1; overflow-y: auto; padding: 8px 10px 20px; }
.ptree-group { margin-bottom: 5px; }
.ptree-title { display: flex; align-items: center; gap: 7px; padding: 6px; cursor: pointer; font-size: 11.5px; font-weight: 600; color: var(--ink-2); border-radius: 6px; user-select: none; white-space: nowrap; }
.ptree-title:hover { background: var(--paper-2); }
.ptree-title .dot { width: 6px; height: 6px; }
.ptree-count { margin-left: auto; font-family: var(--font-mono); font-size: 9.5px; color: var(--ink-3); }
.pitem { display: flex; align-items: center; gap: 9px; padding: 7px 9px; margin: 3px 0 3px 16px; border: 1px solid var(--rule-2); border-radius: 7px; cursor: grab; background: var(--paper); user-select: none; }
.pitem:hover { border-color: var(--accent); background: var(--paper-2); }
.pitem:active { cursor: grabbing; }
.pitem-mono { width: 18px; text-align: center; font-family: var(--font-mono); font-size: 12px; flex-shrink: 0; }
.pitem-name { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* canvas */
.flow-canvas-wrap { flex: 1; position: relative; overflow: hidden; cursor: grab; min-width: 0; }
.flow-canvas-wrap:active { cursor: grabbing; }
.flow-grid { position: absolute; inset: 0; background-image: radial-gradient(var(--rule-2) 1.1px, transparent 1.1px); pointer-events: none; opacity: 0.75; }
.flow-layer { position: absolute; top: 0; left: 0; transform-origin: 0 0; }
/* toolbar */
.flow-toolbar { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; z-index: 6; pointer-events: none; }
.flow-toolbar > * { pointer-events: auto; }
.ft-name { display: flex; align-items: center; gap: 9px; background: var(--surface); border: 1px solid var(--rule-2); border-radius: 8px; padding: 6px 12px; box-shadow: var(--shadow-pop); min-width: 0; }
.ft-name .dot { width: 7px; height: 7px; flex-shrink: 0; }
.ft-input { border: none; outline: none; background: transparent; font-family: var(--font-sans); font-size: 13px; font-weight: 600; color: var(--ink); width: 220px; min-width: 60px; }
.ft-meta { font-size: 10px; color: var(--ink-3); border-left: 1px solid var(--rule); padding-left: 9px; white-space: nowrap; flex-shrink: 0; }
.flow-toolbar .row { background: var(--surface); border: 1px solid var(--rule-2); border-radius: 8px; padding: 5px 6px; box-shadow: var(--shadow-pop); }
.flow-zoom { position: absolute; bottom: 14px; right: 14px; z-index: 6; display: flex; align-items: center; gap: 2px; background: var(--surface); border: 1px solid var(--rule-2); border-radius: 8px; padding: 3px; box-shadow: var(--shadow-pop); }
.flow-zoom button { width: 26px; height: 26px; border: none; background: transparent; color: var(--ink-2); border-radius: 6px; cursor: pointer; display: grid; place-items: center; font-family: var(--font-mono); font-size: 14px; }
.flow-zoom button:hover { background: var(--paper-2); color: var(--ink); }
.flow-zoom span { font-size: 10.5px; color: var(--ink-3); width: 40px; text-align: center; }
/* wires */
.flow-wires { z-index: 1; }
.wire { fill: none; stroke: var(--ink-3); stroke-width: 1.6; cursor: pointer; vector-effect: non-scaling-stroke; }
.wire:hover { stroke: var(--ink-2); stroke-width: 2.4; }
.wire.sel { stroke: var(--accent); stroke-width: 2.6; }
.wire.pending { stroke: var(--accent); stroke-dasharray: 4 3; pointer-events: none; }
.wire.run { stroke: var(--accent); stroke-width: 2.6; stroke-dasharray: 5 4; animation: wireflow 0.5s linear infinite; }
@keyframes wireflow { to { stroke-dashoffset: -18; } }
/* node */
.fnode { position: absolute; background: var(--surface); border: 1px solid var(--rule-2); border-radius: 9px; box-shadow: 0 1px 2px oklch(0.2 0.04 290 / 0.05), 0 6px 16px -10px oklch(0.2 0.04 290 / 0.32); cursor: grab; user-select: none; z-index: 2; }
.fnode:hover { border-color: var(--rule-3); }
.fnode.sel { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring), 0 10px 24px -10px oklch(0.2 0.04 290 / 0.42); z-index: 3; }
.fnode.active { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
.fnode-head { display: flex; align-items: center; gap: 8px; padding: 9px 11px 7px; }
.fnode-mono { width: 22px; height: 22px; border-radius: 6px; border: 1.5px solid var(--ink-3); display: grid; place-items: center; font-family: var(--font-mono); font-size: 11px; font-weight: 600; flex-shrink: 0; background: var(--paper); }
.fnode-title { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }
.fnode-body { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); padding: 0 11px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ports */
.fport { position: absolute; width: 11px; height: 11px; border-radius: 50%; background: var(--surface); border: 1.5px solid var(--ink-3); cursor: crosshair; z-index: 4; transition: transform .1s; }
.fport.in { left: -6px; }
.fport.out { right: -6px; border-color: var(--ink-2); }
.fport.out.neg { border-color: var(--danger); }
.fport:hover { border-color: var(--accent); background: var(--accent); transform: scale(1.25); }
.fport-lbl { position: absolute; top: 50%; transform: translateY(-50%); font-family: var(--font-mono); font-size: 7.5px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-3); white-space: nowrap; pointer-events: none; }
.fport-lbl.in { left: 13px; }
.fport-lbl.out { right: 13px; }
/* inspector */
.flow-inspector { width: 286px; flex-shrink: 0; border-left: 1px solid var(--rule); background: var(--surface); display: flex; flex-direction: column; }
.flow-insp-head { display: flex; align-items: center; gap: 10px; padding: 13px 15px; border-bottom: 1px solid var(--rule); }
.flow-insp-head > div { flex: 1; min-width: 0; }
.fi-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.flow-insp-body { flex: 1; overflow-y: auto; padding: 15px; }
.flow-insp-foot { padding: 12px 15px; border-top: 1px solid var(--rule); }
.flow-insp-empty { padding: 18px 16px; }
.flow-legend { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
.flow-legend .fl { display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); }
.flow-legend .fl .dot { width: 7px; height: 7px; }
/* toggle switch */
.fswitch { width: 32px; height: 18px; border-radius: 10px; border: 1px solid var(--rule-2); background: var(--paper-2); position: relative; cursor: pointer; padding: 0; flex-shrink: 0; transition: background .12s; }
.fswitch span { position: absolute; top: 1.5px; left: 1.5px; width: 13px; height: 13px; border-radius: 50%; background: var(--ink-3); transition: transform .12s, background .12s; }
.fswitch.on { background: var(--accent); border-color: var(--accent); }
.fswitch.on span { transform: translateX(14px); background: #fff; }
/* palette-drag ghost */
.flow-ghost { position: fixed; z-index: 200; pointer-events: none; display: flex; align-items: center; gap: 8px; background: var(--surface); border: 1px solid var(--accent); border-radius: 8px; padding: 7px 11px 7px 8px; font-size: 12.5px; font-weight: 600; box-shadow: var(--shadow-pop); transform: translate(-50%, -50%); opacity: 0.96; }
.flow-ghost .fnode-mono { width: 20px; height: 20px; }
+17 -11
View File
@@ -1,5 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Breakpilot", title: "Breakpilot",
@@ -8,18 +11,21 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html
<body lang="en"
style={{ data-theme="light"
margin: 0, className={`${GeistSans.variable} ${GeistMono.variable}`}
fontFamily: suppressHydrationWarning
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif',
background: "#fafafa",
color: "#111",
}}
> >
{children} <head>
</body> {/* Restore the user's last theme before paint to avoid a flash. */}
<script
dangerouslySetInnerHTML={{
__html: `try{var t=localStorage.getItem("bp.theme");if(t==="dark"||t==="light")document.documentElement.setAttribute("data-theme",t);}catch(e){}`,
}}
/>
</head>
<body>{children}</body>
</html> </html>
); );
} }
+64
View File
@@ -0,0 +1,64 @@
import Link from "next/link";
import { Brand } from "./Brand";
import type { PortalTenant } from "@/lib/portal-data";
// Full-page 410 lockout. Replaces the entire shell — no rail, no nav,
// no chrome. Only "Export remaining data" + "Contact support" + sign-out.
export function ArchivedLockout({ tenant }: { tenant: PortalTenant }) {
return (
<div className="lockout">
<div className="lockout-card panel bracket">
<div className="panel-pad" style={{ padding: 26 }}>
<div className="row between" style={{ alignItems: "flex-start" }}>
<Brand sub="Customer portal" />
<span
className="mono"
style={{ fontSize: 10, letterSpacing: "0.13em", color: "var(--ink-3)" }}
>
HTTP 410 · GONE
</span>
</div>
<h1 style={{ fontSize: 22, marginTop: 22 }}>
This tenant&apos;s data-retention window has closed.
</h1>
<p style={{ color: "var(--ink-2)", marginTop: 8, fontSize: 13 }}>
{tenant.name} ({tenant.legalType}) was archived on{" "}
<span className="mono">{tenant.archivedOn ?? "—"}</span> and its
retention window closed on{" "}
<span className="mono">{tenant.retentionClosed ?? "—"}</span>. The
portal no longer surfaces the tenant&apos;s findings, evidence, or
audit log.
</p>
<div className="lockout-rule" />
<dl className="dl">
<dt>Tenant</dt>
<dd>
{tenant.name} · <span className="mono">{tenant.id}</span>
</dd>
<dt>Archived</dt>
<dd className="mono">{tenant.archivedOn ?? "—"}</dd>
<dt>Retention closed</dt>
<dd className="mono">{tenant.retentionClosed ?? "—"}</dd>
<dt>Support</dt>
<dd className="mono">support@breakpilot.eu</dd>
</dl>
<div className="row" style={{ gap: 8, marginTop: 22 }}>
<button type="button" className="btn btn-primary">
Export remaining data
</button>
<Link
href="mailto:support@breakpilot.eu?subject=Reactivate archived tenant"
className="btn"
>
Contact support
</Link>
<span className="spacer" />
<Link href="/" className="btn btn-ghost">
Sign out
</Link>
</div>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// Breakpilot wordmark. The trailing "." is added via the .brand-name::after
// pseudo-element in globals.css, in the brand violet.
export function Brand({
sub,
variant = "dark",
}: {
sub?: string;
variant?: "dark" | "light";
}) {
return (
<div className="brand">
<span
className="brand-mark"
aria-hidden
style={variant === "light" ? { background: "#fff", color: "var(--accent)" } : undefined}
>
B
</span>
<div className="col" style={{ gap: 0 }}>
<span className="brand-name">Breakpilot</span>
{sub ? <span className="brand-sub">{sub}</span> : null}
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import Link from "next/link";
import type { TenantStatus } from "@/lib/session";
export type LifelineTenant = {
status: TenantStatus;
slug: string;
plan?: string;
seats?: { used: number; total: number };
trialDaysLeft?: number;
trialEnds?: string;
frozenReason?: string;
};
// Top full-width rail. Renders different chrome per `tenant.status` per
// PLATFORM_ARCHITECTURE.md §5c.
// - active → quiet hairline rail; plan · region · seats
// - trial → amber rail; countdown + Upgrade CTA
// - frozen → red rail; reason + Re-activate
// - demo → SANDBOX rail; Exit demo
// - archived → never rendered here; the layout swaps in the 410 lockout
export function Lifeline({ tenant }: { tenant: LifelineTenant }) {
if (tenant.status === "active") {
const seats = tenant.seats ? `${tenant.seats.used}/${tenant.seats.total} seats` : "";
return (
<div className="lifeline is-active" role="status">
<span className="ll-dot" style={{ background: "var(--ok)" }} />
<span className="ll-strong">Active</span>
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
All products operational
</span>
<span className="ll-spacer" />
<span className="muted mono" style={{ fontSize: 10.5 }}>
{tenant.plan ?? "—"} · eu-central · {seats}
</span>
</div>
);
}
if (tenant.status === "trial") {
return (
<div className="lifeline is-trial" role="status">
<span className="ll-dot" style={{ background: "var(--warn)" }} />
<span className="ll-strong">
<span className="ll-count">{tenant.trialDaysLeft ?? 0}</span> days left on trial
</span>
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
ends <span className="mono">{tenant.trialEnds ?? "—"}</span>
</span>
<span className="ll-spacer" />
<Link href={`/${tenant.slug}/billing`} className="btn btn-sm btn-accent">
Upgrade now
</Link>
</div>
);
}
if (tenant.status === "frozen") {
return (
<div className="lifeline is-frozen" role="status">
<span className="ll-dot" style={{ background: "var(--danger)" }} />
<span className="ll-strong">Read-only</span>
<span
className="ll-muted-detail"
style={{ fontSize: 11.5, color: "var(--ink-2)" }}
>
{tenant.frozenReason ?? "Payment failed."} Writes are disabled.
</span>
<span className="ll-spacer" />
<Link href={`/${tenant.slug}/billing`} className="btn btn-sm btn-accent">
Re-activate to continue
</Link>
</div>
);
}
if (tenant.status === "demo") {
return (
<div className="lifeline is-demo" role="status">
<span className="ll-dot" style={{ background: "var(--ink-4)" }} />
<span className="ll-strong mono" style={{ letterSpacing: "0.06em" }}>
SANDBOX
</span>
<span className="muted ll-muted-detail" style={{ fontSize: 11.5 }}>
Shared demo tenant · data resets nightly · changes aren&apos;t saved
</span>
<span className="ll-spacer" />
</div>
);
}
return null;
}
+31
View File
@@ -0,0 +1,31 @@
import type { CSSProperties } from "react";
export function Monogram({
text,
size = 36,
variant = "default",
style,
}: {
text: string;
size?: number;
variant?: "default" | "soon" | "tenant" | "fixture";
style?: CSSProperties;
}) {
const className =
variant === "soon"
? "monogram soon"
: variant === "tenant"
? "tenant-mono"
: variant === "fixture"
? "fixture-mono"
: "monogram";
return (
<span
className={className}
style={{ width: size, height: size, fontSize: Math.max(10, Math.round(size * 0.36)), ...style }}
aria-hidden
>
{text}
</span>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutGrid,
Boxes,
Workflow,
Building2,
Users,
CreditCard,
ScrollText,
Shield,
Lock,
ChevronRight,
} from "lucide-react";
import type { ComponentType, SVGProps } from "react";
import type { SessionWithExtras, OrgRole } from "@/lib/session";
import { Brand } from "./Brand";
import { Monogram } from "./Monogram";
import { canAccess, type RouteKey } from "@/lib/fixtures";
type Icon = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
type NavEntry = {
route: RouteKey;
href: string;
label: string;
icon: Icon;
/** human label shown when the route is locked for the current user */
need: string;
};
type NavGroup = { title: string; items: NavEntry[] };
function buildGroups(slug: string): NavGroup[] {
return [
{
title: "Workspace",
items: [
{ route: "dashboard", href: `/${slug}/dashboard`, label: "Overview", icon: LayoutGrid, need: "—" },
{ route: "products", href: `/${slug}/products`, label: "Products", icon: Boxes, need: "USER" },
{ route: "workflows", href: `/${slug}/workflows`, label: "Workflows", icon: Workflow, need: "IT_ADMIN" },
],
},
{
title: "Administration",
items: [
{ route: "org", href: `/${slug}/settings`, label: "Organization", icon: Building2, need: "IT_ADMIN" },
{ route: "team", href: `/${slug}/settings/users`, label: "Team", icon: Users, need: "IT_ADMIN" },
{ route: "billing", href: `/${slug}/billing`, label: "Billing", icon: CreditCard, need: "CXO / FINANCE" },
{ route: "audit", href: `/${slug}/audit`, label: "Audit log", icon: ScrollText, need: "LEGAL" },
],
},
{
title: "Settings",
items: [
{ route: "sso", href: `/${slug}/settings/integrations`, label: "SSO", icon: Shield, need: "IT_ADMIN" },
],
},
];
}
export function NavRail({
slug,
tenant,
session,
}: {
slug: string;
tenant: { name: string; short: string; mono: string; plan?: string; status: string };
session: SessionWithExtras;
}) {
const pathname = usePathname() || "";
const roles: OrgRole[] = session.org_roles ?? [];
const groups = buildGroups(slug);
return (
<aside className="rail">
<div className="rail-head">
<Brand sub="Customer portal" />
</div>
<button type="button" className="tenant-switch" aria-label="Tenant">
<Monogram text={tenant.mono} size={24} variant="tenant" />
<div className="tenant-meta">
<div className="tn">{tenant.name}</div>
<div className="ts mono">
<span className="dot accent" /> {tenant.plan ?? "—"} · {tenant.status}
</div>
</div>
<ChevronRight size={14} style={{ color: "var(--ink-3)" }} />
</button>
<nav className="nav" aria-label="Primary">
{groups.map((g) => (
<div key={g.title} className="nav-group">
<div className="nav-group-title label-micro">{g.title}</div>
{g.items.map((it) => {
const allowed = canAccess(roles, it.route);
const active = pathname.startsWith(it.href);
const Icon = it.icon;
const className =
"nav-item" + (active ? " active" : "") + (!allowed ? " disabled" : "");
if (!allowed) {
return (
<div
key={it.route}
className={className}
role="link"
aria-disabled
title={`Requires ${it.need}`}
>
<span className="nav-ico">
<Icon size={15} />
</span>
{it.label}
<span className="nav-lock">
<Lock size={11} />
</span>
</div>
);
}
return (
<Link key={it.route} href={it.href} className={className}>
<span className="nav-ico">
<Icon size={15} />
</span>
{it.label}
</Link>
);
})}
</div>
))}
</nav>
<div className="rail-foot">
<div className="user-chip">
<span className="avatar">
{(session.user?.name ?? session.user?.email ?? "?")
.split(/[\s@.]/)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase())
.join("")}
</span>
<div className="user-meta">
<div className="un">{session.user?.name ?? session.user?.email ?? "—"}</div>
<div className="ue">{roles.join(" · ") || "(no roles)"}</div>
</div>
</div>
</div>
</aside>
);
}
+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>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode, CSSProperties } from "react";
// Wraps content in the design's bordered panel. `bracket` adds the
// distinctive corner ticks. `title` + `tail` produce the panel-head
// (left-aligned title, right-aligned tail content like filters).
export function Panel({
title,
tail,
bracket,
pad = true,
style,
className,
children,
}: {
title?: ReactNode;
tail?: ReactNode;
bracket?: boolean;
pad?: boolean;
style?: CSSProperties;
className?: string;
children: ReactNode;
}) {
const cls = ["panel", bracket ? "bracket" : "", className ?? ""].filter(Boolean).join(" ");
return (
<section className={cls} style={style}>
{title || tail ? (
<header className="panel-head">
{title ? <span className="ph-title">{title}</span> : null}
{tail ? <span className="ph-tail">{tail}</span> : null}
</header>
) : null}
<div className={pad ? "panel-pad" : ""}>{children}</div>
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Severity } from "@/lib/fixtures";
export function Sev({ level }: { level: Severity }) {
return (
<span className={`sev ${level}`}>
<span className="bar" />
{level.toUpperCase()}
</span>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { Sun, Moon } from "lucide-react";
type Theme = "light" | "dark";
function readTheme(): Theme {
if (typeof document === "undefined") return "light";
const attr = document.documentElement.getAttribute("data-theme");
return attr === "dark" ? "dark" : "light";
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setTheme(readTheme());
setMounted(true);
}, []);
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try {
localStorage.setItem("bp.theme", next);
} catch {
/* no-op */
}
setTheme(next);
}
return (
<button
type="button"
className="theme-toggle"
onClick={toggle}
aria-label="Toggle theme"
title={mounted ? `Switch to ${theme === "dark" ? "light" : "dark"} theme` : "Toggle theme"}
>
{mounted && theme === "dark" ? <Sun size={15} /> : <Moon size={15} />}
</button>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Search } from "lucide-react";
import { ThemeToggle } from "./ThemeToggle";
export type Crumb = { label: string; href?: string };
// The single 48px topbar. Crumbs left, then a spacer, then the ⌘K button
// and the theme toggle.
export function Topbar({ crumbs }: { crumbs: Crumb[] }) {
return (
<header className="topbar">
<div className="crumbs">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
return (
<span key={i} className="row" style={{ gap: 8 }}>
{i > 0 ? <span className="c-sep">/</span> : null}
{last ? (
<span className="c-cur">{c.label}</span>
) : (
<span className="c-prev">{c.label}</span>
)}
</span>
);
})}
</div>
<div className="topbar-spacer" />
<button type="button" className="cmdk-btn" aria-label="Open command palette">
<Search size={13} />
<span>Jump</span>
<span className="kbd">K</span>
</button>
<ThemeToggle />
</header>
);
}
+49
View File
@@ -0,0 +1,49 @@
// 5x7 calendar heatmap. Cell value 0..4 → accent alpha ramp.
const RAMP = [
"var(--paper-2)",
"color-mix(in oklch, var(--accent) 18%, var(--paper))",
"color-mix(in oklch, var(--accent) 38%, var(--paper))",
"color-mix(in oklch, var(--accent) 65%, var(--paper))",
"var(--accent)",
];
export function Heatmap({ data, cell = 18, gap = 4 }: { data: number[]; cell?: number; gap?: number }) {
// 5 weeks across × 7 days down, but the layout is 7 columns (days) × 5 rows (weeks).
// We'll render in DOM order matching the source data: 35 cells, row-major.
const cols = 7;
return (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${cell}px)`,
gap,
}}
aria-hidden
>
{data.map((v, i) => (
<span
key={i}
style={{
width: cell,
height: cell,
borderRadius: 3,
background: RAMP[Math.max(0, Math.min(4, v))] ?? RAMP[0],
border: "1px solid var(--rule)",
}}
/>
))}
</div>
);
}
export function HeatLegend() {
return (
<div className="heatlegend">
<span>low</span>
{RAMP.map((c, i) => (
<span key={i} className="hc" style={{ background: c }} />
))}
<span>high</span>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
// Circular gauge for "controls passing". Two-stop arc with the brand
// violet on the filled portion.
export function Ring({
value,
total,
size = 56,
stroke = 5,
color = "var(--accent)",
track = "var(--rule-2)",
}: {
value: number;
total: number;
size?: number;
stroke?: number;
color?: string;
track?: string;
}) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const pct = total > 0 ? value / total : 0;
const dash = c * pct;
return (
<svg width={size} height={size} aria-hidden>
<g transform={`translate(${size / 2}, ${size / 2}) rotate(-90)`}>
<circle r={r} fill="none" stroke={track} strokeWidth={stroke} />
<circle
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={`${dash} ${c - dash}`}
/>
</g>
</svg>
);
}
@@ -0,0 +1,34 @@
// Compact bar chart for KPI rail. Last N days, fixed height.
export function Sparkbars({
data,
width = 96,
height = 24,
color = "var(--ink-2)",
}: {
data: number[];
width?: number;
height?: number;
color?: string;
}) {
if (!data.length) return null;
const max = Math.max(...data, 1);
const barW = width / data.length;
return (
<svg width={width} height={height} aria-hidden>
{data.map((v, i) => {
const h = Math.max(1, (v / max) * (height - 2));
return (
<rect
key={i}
x={i * barW + 0.5}
y={height - h}
width={Math.max(1, barW - 1.2)}
height={h}
fill={color}
opacity={0.85}
/>
);
})}
</svg>
);
}
@@ -0,0 +1,36 @@
// Area sparkline. Used for findings/evidence series on the KPI rail
// and the big 30-day flow panel.
export function Sparkline({
data,
width = 480,
height = 80,
stroke = "var(--accent)",
fill = "var(--accent-2)",
}: {
data: number[];
width?: number;
height?: number;
stroke?: string;
fill?: string;
}) {
if (data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = Math.max(1, max - min);
const stepX = width / (data.length - 1);
const points = data.map((v, i) => {
const x = i * stepX;
const y = height - 2 - ((v - min) / range) * (height - 4);
return [x, y] as const;
});
const line = points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`).join(" ");
const last = points[points.length - 1];
const first = points[0];
const area = `${line} L${last[0].toFixed(2)},${height} L${first[0].toFixed(2)},${height} Z`;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden>
<path d={area} fill={fill} />
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} />
</svg>
);
}
+56
View File
@@ -0,0 +1,56 @@
// Horizontal stacked bar for severity composition. Uses the four
// --sev-* tokens.
import type { Severity } from "@/lib/fixtures";
const ORDER: Severity[] = ["critical", "high", "medium", "low"];
const COLOR: Record<Severity, string> = {
critical: "var(--sev-critical)",
high: "var(--sev-high)",
medium: "var(--sev-medium)",
low: "var(--sev-low)",
};
export function StackBar({
counts,
height = 7,
}: {
counts: Record<Severity, number>;
height?: number;
}) {
const total = ORDER.reduce((acc, k) => acc + (counts[k] ?? 0), 0);
if (total === 0) {
return (
<div
style={{
height,
borderRadius: 3,
background: "var(--paper-2)",
border: "1px solid var(--rule)",
}}
/>
);
}
return (
<div
style={{
height,
borderRadius: 3,
overflow: "hidden",
display: "flex",
border: "1px solid var(--rule)",
}}
>
{ORDER.map((k) => {
const n = counts[k] ?? 0;
if (n === 0) return null;
return (
<div
key={k}
title={`${k}: ${n}`}
style={{ width: `${(n / total) * 100}%`, background: COLOR[k] }}
/>
);
})}
</div>
);
}
+800
View File
@@ -0,0 +1,800 @@
// Mock fixtures for portal dev mode — port of the handoff `data.js`.
//
// Used in two places:
// 1. MSW handlers (intercept `/api/tenants/...` and friends so the portal
// renders without the tenant-registry service up).
// 2. Server components that render the design with realistic data when no
// live backend is available.
//
// Deterministic by design — same NOW + same seed = same data every reload.
import type { OrgRole, TenantStatus } from "@/lib/session";
// ---- deterministic RNG (mulberry32) -------------------------------------
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const pick = <T>(r: () => number, arr: T[]): T => arr[Math.floor(r() * arr.length)]!;
// Frozen "now" so every reload is identical.
export const NOW = new Date("2026-06-04T09:12:00Z");
const DAY = 86_400_000;
const ago = (ms: number) => new Date(NOW.getTime() - ms);
// ---- product catalog ----------------------------------------------------
export type ProductStatus = "live" | "soon";
export type ProductDef = {
id: string;
slug: string;
name: string;
mono: string;
status: ProductStatus;
blurb: string;
frameworks: string[];
};
export const PRODUCTS: ProductDef[] = [
{
id: "compliance-scanner",
slug: "compliance-scanner",
name: "Compliance Scanner",
mono: "CS",
status: "live",
blurb: "Continuous control scanning across cloud, code & infrastructure.",
frameworks: ["ISO 27001", "BSI C5", "NIS2"],
},
{
id: "certifai",
slug: "certifai",
name: "CERTifAI",
mono: "Ai",
status: "live",
blurb: "AI-act conformity & model evidence — EU AI Act Annex IV dossiers.",
frameworks: ["EU AI Act", "ISO 42001"],
},
{
id: "policyforge",
slug: "policyforge",
name: "PolicyForge",
mono: "PF",
status: "soon",
blurb: "Policy authoring with mapped controls and approval trails.",
frameworks: ["ISO 27001", "TISAX"],
},
{
id: "residency",
slug: "residency-monitor",
name: "Residency Monitor",
mono: "RM",
status: "soon",
blurb: "Data-residency & transfer-impact monitoring for GDPR Ch. V.",
frameworks: ["GDPR", "Schrems II"],
},
];
export const productById = (id: string): ProductDef | undefined =>
PRODUCTS.find((p) => p.id === id || p.slug === id);
// ---- pools --------------------------------------------------------------
const CONTROLS: [string, string][] = [
["ISO 27001 A.8.3", "Information access restriction"],
["ISO 27001 A.5.23", "Information security for cloud services"],
["ISO 27001 A.8.16", "Monitoring activities"],
["ISO 27001 A.8.24", "Use of cryptography"],
["BSI C5 KRY-03", "Encryption of data in transit"],
["BSI C5 IDM-09", "Privileged access review"],
["BSI C5 RB-21", "Logging of security events"],
["NIS2 Art.21 (2c)", "Business continuity & backup"],
["NIS2 Art.21 (2d)", "Supply-chain security"],
["EU AI Act Art.9", "Risk-management system"],
["EU AI Act Art.12", "Record-keeping / logging"],
["EU AI Act Annex IV", "Technical documentation"],
["GDPR Art.32", "Security of processing"],
["GDPR Art.30", "Records of processing activities"],
["ISO 42001 6.1.2", "AI risk assessment"],
["TISAX 1.5.1", "Identity & access management"],
];
const FINDING_TITLES = [
"S3 bucket without enforced TLS policy",
"IAM role with wildcard privileges",
"Production DB snapshot unencrypted at rest",
"Audit log retention below 365 days",
"Model card missing intended-purpose section",
"Public container registry exposes image digests",
"MFA not enforced for 3 admin accounts",
"Backup restore not tested in 90 days",
"Sub-processor list out of date in RoPA",
"Training-data lineage not recorded",
"Egress to non-EU region detected",
"Secrets found in CI pipeline variables",
"Vendor TIA missing for US-based CDN",
"Container image runs as root",
"Conformity dossier lacks bias-evaluation evidence",
"Logging disabled on inference endpoint",
];
// ---- types --------------------------------------------------------------
export type Severity = "critical" | "high" | "medium" | "low";
export type Finding = {
id: string;
severity: Severity;
product: string;
control: string;
controlName: string;
title: string;
status: "open" | "resolved";
opened: string;
ageDays: number;
owner: string;
};
export type ActivityRow = {
ts: Date;
when: string;
date: string;
time: string;
actor: string;
verb: string;
product: string;
target: string;
};
export type AuditRow = {
ts: Date;
date: string;
time: string;
event: string;
actor: string;
product: string;
ip: string;
result: "ok" | "denied";
};
export type Invoice = {
id: string;
period: string;
issued: string;
seats: number;
net: number;
vat: number;
total: number;
status: "paid" | "due";
};
export type TeamMember = {
name: string;
email: string;
roles: OrgRole[];
status: "active" | "invited";
last: string;
};
export type TenantMetrics = {
openFindings: number;
critical: number;
lastScan: string;
lastScanDate: string;
evidence: number;
controlsPassing: number;
controlsTotal: number;
severity: Record<Severity, number>;
resolved7: number;
findingsDelta: number;
};
export type TenantSeries = {
findings30: number[];
evidence30: number[];
controls30: number[];
heatmap: number[];
prodSeries: Record<string, number[]>;
};
export type TenantSeats = { used: number; total: number };
export type TenantRecord = {
id: string;
domain: string;
name: string;
short: string;
mono: string;
status: TenantStatus;
legalType: string;
city: string;
country: string;
vat: string;
plan: string;
planCode: string;
seats: TenantSeats;
monthly: number;
contact: string;
contactEmail: string;
renewal: string;
since: string;
entitled: string[];
trialing: string[];
trialDaysLeft?: number;
trialEnds?: string;
frozenReason?: string;
archivedOn?: string;
retentionClosed?: string;
seed: number;
findingCount: number;
// generated
products: ProductDef[];
findings: Finding[];
activity: ActivityRow[];
audit: AuditRow[];
invoices: Invoice[];
team: TeamMember[];
series: TenantSeries;
metrics: TenantMetrics;
};
// ---- date helpers -------------------------------------------------------
function fmtDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function fmtTime(d: Date): string {
return d.toISOString().slice(11, 16);
}
export function relTime(d: Date): string {
const diff = NOW.getTime() - d.getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return m + "m ago";
const h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
const days = Math.floor(h / 24);
if (days < 30) return days + "d ago";
return Math.floor(days / 30) + "mo ago";
}
export { fmtDate, fmtTime };
// ---- generators ---------------------------------------------------------
function genFindings(seed: number, products: string[], count: number): Finding[] {
const r = rng(seed);
const out: Finding[] = [];
const sevPool: Severity[] = [
"critical",
"high",
"high",
"medium",
"medium",
"medium",
"low",
"low",
];
for (let i = 0; i < count; i++) {
const ctrl = pick(r, CONTROLS);
const prod = pick(r, products);
const ageDays = Math.floor(r() * 41) + 1;
const sev = pick(r, sevPool);
const resolved = r() < 0.32;
out.push({
id: "FND-" + (1000 + Math.floor(r() * 8999)),
severity: sev,
product: prod,
control: ctrl[0],
controlName: ctrl[1],
title: pick(r, FINDING_TITLES),
status: resolved ? "resolved" : "open",
opened: fmtDate(ago(ageDays * DAY)),
ageDays,
owner: "—",
});
}
const sevRank: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3 };
out.sort(
(a, b) =>
(a.status === b.status ? 0 : a.status === "open" ? -1 : 1) ||
sevRank[a.severity] - sevRank[b.severity] ||
a.ageDays - b.ageDays
);
return out;
}
function genActivity(
seed: number,
products: string[],
people: string[],
count: number
): ActivityRow[] {
const r = rng(seed);
const verbs: [string, string | null][] = [
["ran a scan on", "compliance-scanner"],
["resolved finding", null],
["exported evidence for", null],
["invited", null],
["updated control mapping in", null],
["approved dossier in", "certifai"],
["acknowledged finding", null],
["generated Annex IV report in", "certifai"],
["rotated API key for", null],
["assigned owner to", null],
["downloaded audit bundle for", null],
];
const out: ActivityRow[] = [];
let cursor = 0;
for (let i = 0; i < count; i++) {
cursor += Math.floor(r() * 9 * DAY) + 3_600_000;
const d = ago(cursor);
const v = pick(r, verbs);
const prod = v[1] || pick(r, products);
const target = pick(r, [
"FND-" + (1000 + Math.floor(r() * 8999)),
prod,
pick(r, CONTROLS)[0],
pick(r, people).split(" ")[0] + "@",
]);
out.push({
ts: d,
when: relTime(d),
date: fmtDate(d),
time: fmtTime(d),
actor: pick(r, people),
verb: v[0],
product: prod,
target,
});
}
out.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return out;
}
function genAudit(
seed: number,
products: string[],
people: string[],
count: number
): AuditRow[] {
const r = rng(seed);
const events = [
"auth.login",
"auth.login",
"scan.completed",
"finding.resolved",
"evidence.exported",
"user.invited",
"billing.viewed",
"settings.sso.viewed",
"product.opened",
"finding.assigned",
"report.generated",
"apikey.rotated",
"auth.failed",
"policy.published",
];
const out: AuditRow[] = [];
let cursor = 0;
for (let i = 0; i < count; i++) {
cursor += Math.floor(r() * 2.4 * DAY) + 600_000;
const d = ago(cursor);
const ev = pick(r, events);
out.push({
ts: d,
date: fmtDate(d),
time: fmtTime(d),
event: ev,
actor: pick(r, people),
product:
ev.startsWith("auth") || ev.startsWith("billing") || ev.startsWith("settings")
? "—"
: pick(r, products),
ip: [10, Math.floor(r() * 255), Math.floor(r() * 255), Math.floor(r() * 255)].join("."),
result: ev === "auth.failed" ? "denied" : "ok",
});
}
out.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return out;
}
function genInvoices(seed: number, monthly: number, seats: number, months: number): Invoice[] {
const r = rng(seed);
const out: Invoice[] = [];
for (let i = 0; i < months; i++) {
const d = new Date(NOW.getFullYear(), NOW.getMonth() - i, 1);
const amt = monthly + (i === 0 ? 0 : Math.floor((r() - 0.5) * 4) * 120);
out.push({
id: "INV-" + d.getFullYear() + "-" + String(months - i).padStart(4, "0"),
period: d.toLocaleString("en", { month: "short" }) + " " + d.getFullYear(),
issued: fmtDate(d),
seats,
net: amt,
vat: Math.round(amt * 0.19),
total: amt + Math.round(amt * 0.19),
status: i === 0 ? "due" : "paid",
});
}
return out;
}
// ---- tenant build -------------------------------------------------------
type PersonSeed = [string, string, OrgRole[]] | [string, string, OrgRole[], "active" | "invited"];
type TenantSeed = Omit<
TenantRecord,
"products" | "findings" | "activity" | "audit" | "invoices" | "team" | "series" | "metrics"
> & {
people: PersonSeed[];
};
function buildTenant(cfg: TenantSeed): TenantRecord {
const findingProducts = cfg.entitled.filter((p) =>
["compliance-scanner", "certifai"].includes(p)
);
const findings = genFindings(
cfg.seed,
findingProducts.length ? findingProducts : ["compliance-scanner"],
cfg.findingCount
);
const people = cfg.people.map((p) => p[0]);
const activity = genActivity(cfg.seed + 7, cfg.entitled, people, 26);
const audit = genAudit(cfg.seed + 13, cfg.entitled, people, 38);
const invoices = genInvoices(cfg.seed + 21, cfg.monthly, cfg.seats.total, 9);
const team: TeamMember[] = cfg.people.map((p, i) => ({
name: p[0],
email: p[1] + "@" + cfg.domain,
roles: p[2],
status: p[3] || "active",
last: p[3] === "invited" ? "—" : relTime(ago(Math.floor(i * 1.7 + 0.4) * DAY)),
}));
const open = findings.filter((f) => f.status === "open");
const crit = open.filter((f) => f.severity === "critical").length;
const lastScan = activity.find((a) => a.verb.includes("scan"));
const evidence = 180 + Math.floor(cfg.seed % 400);
const controlsPassing = 88 + (cfg.seed % 9);
const r = rng(cfg.seed + 99);
const findings30: number[] = [];
let cur = open.length + Math.floor(r() * 6) + 3;
for (let i = 0; i < 30; i++) {
cur += Math.round((r() - 0.45) * 4);
cur = Math.max(1, cur);
findings30.push(cur);
}
findings30[29] = open.length;
const evidence30: number[] = [];
let ev = evidence - Math.floor(r() * 60) - 30;
for (let i = 0; i < 30; i++) {
ev += Math.floor(r() * 5);
evidence30.push(ev);
}
evidence30[29] = evidence;
const controls30: number[] = [];
for (let i = 0; i < 30; i++) {
controls30.push(
Math.min(99, controlsPassing + Math.round((r() - 0.6) * 6) - (i < 24 ? 2 : 0))
);
}
controls30[29] = controlsPassing;
const heatmap: number[] = [];
for (let i = 0; i < 35; i++) {
const x = r();
const dow = i % 7;
const weekend = dow >= 5 ? 0.55 : 1;
heatmap.push(x * weekend < 0.3 ? 0 : x * weekend < 0.55 ? 1 : x * weekend < 0.78 ? 2 : x * weekend < 0.92 ? 3 : 4);
}
const prodSeries: Record<string, number[]> = {};
["compliance-scanner", "certifai"].forEach((pid, k) => {
const rr = rng(cfg.seed + 17 * (k + 1));
const base = open.filter((f) => f.product === pid).length;
const arr: number[] = [];
let c = base + Math.floor(rr() * 4) + 1;
for (let i = 0; i < 20; i++) {
c += Math.round((rr() - 0.45) * 3);
c = Math.max(0, c);
arr.push(c);
}
arr[19] = base;
prodSeries[pid] = arr;
});
return {
...cfg,
products: PRODUCTS,
findings,
activity,
audit,
invoices,
team,
series: { findings30, evidence30, controls30, heatmap, prodSeries },
metrics: {
openFindings: open.length,
critical: crit,
lastScan: lastScan ? lastScan.when : "—",
lastScanDate: lastScan ? lastScan.date : "—",
evidence,
controlsPassing,
controlsTotal: 240,
severity: {
critical: open.filter((f) => f.severity === "critical").length,
high: open.filter((f) => f.severity === "high").length,
medium: open.filter((f) => f.severity === "medium").length,
low: open.filter((f) => f.severity === "low").length,
},
resolved7: 1 + (cfg.seed % 5),
findingsDelta: findings30[29] - findings30[22],
},
};
}
// ---- tenants ------------------------------------------------------------
export const TENANTS: Record<string, TenantRecord> = {
acme: buildTenant({
id: "acme",
domain: "acme.eu",
name: "Acme Logistik GmbH",
short: "Acme",
mono: "AC",
status: "active",
legalType: "GmbH",
city: "München, DE",
country: "Germany",
vat: "DE 811 204 557",
plan: "Scale",
planCode: "BP-SCALE",
seats: { used: 34, total: 50 },
monthly: 4200,
contact: "Lena Brandt",
contactEmail: "lena.brandt@acme.eu",
renewal: "2026-11-01",
since: "2023-04-12",
entitled: ["compliance-scanner", "certifai"],
trialing: [],
seed: 1337,
findingCount: 13,
people: [
["Lena Brandt", "lena.brandt", ["IT_ADMIN", "CXO"]],
["Tomas Vogel", "tomas.vogel", ["USER"]],
["Aylin Demir", "aylin.demir", ["LEGAL"]],
["Jonas Weber", "jonas.weber", ["FINANCE"]],
["Sophie Maurer", "sophie.maurer", ["USER"]],
["Lukas Berger", "lukas.berger", ["USER", "LEGAL"]],
["Paul Schmid", "paul.schmid", ["CXO"]],
["Nora Fischer", "nora.fischer", ["USER"], "invited"],
],
}),
hello: buildTenant({
id: "hello",
domain: "hello.io",
name: "Hallo Software AG",
short: "Hallo",
mono: "HA",
status: "trial",
trialDaysLeft: 8,
trialEnds: "2026-06-12",
legalType: "AG",
city: "Berlin, DE",
country: "Germany",
vat: "DE 290 117 884",
plan: "Trial — Growth",
planCode: "BP-TRIAL",
seats: { used: 6, total: 10 },
monthly: 0,
contact: "Marie Keller",
contactEmail: "marie.keller@hello.io",
renewal: "—",
since: "2026-05-15",
entitled: ["compliance-scanner"],
trialing: ["certifai"],
seed: 4242,
findingCount: 9,
people: [
["Marie Keller", "marie.keller", ["IT_ADMIN"]],
["Felix Wagner", "felix.wagner", ["USER"]],
["Ada Novak", "ada.novak", ["USER", "CXO"]],
["Stefan Huber", "stefan.huber", ["FINANCE"], "invited"],
],
}),
globex: buildTenant({
id: "globex",
domain: "globex.at",
name: "Globex Energie GmbH",
short: "Globex",
mono: "GX",
status: "frozen",
frozenReason: "Payment failed — invoice INV-2026-0009 overdue 14 days.",
legalType: "GmbH",
city: "Wien, AT",
country: "Austria",
vat: "ATU 6634 2178",
plan: "Scale",
planCode: "BP-SCALE",
seats: { used: 22, total: 25 },
monthly: 3100,
contact: "Stefan Huber",
contactEmail: "stefan.huber@globex.at",
renewal: "overdue",
since: "2022-09-01",
entitled: ["compliance-scanner", "certifai"],
trialing: [],
seed: 909,
findingCount: 11,
people: [
["Stefan Huber", "stefan.huber", ["IT_ADMIN"]],
["Nora Fischer", "nora.fischer", ["LEGAL"]],
["Lukas Berger", "lukas.berger", ["FINANCE"]],
["Sophie Maurer", "sophie.maurer", ["USER"]],
["Paul Schmid", "paul.schmid", ["USER"]],
],
}),
oldco: buildTenant({
id: "oldco",
domain: "altmann.de",
name: "Altmann & Co. KG",
short: "Altmann",
mono: "AL",
status: "archived",
archivedOn: "2026-03-30",
retentionClosed: "2026-05-30",
legalType: "KG",
city: "Hamburg, DE",
country: "Germany",
vat: "DE 118 552 030",
plan: "—",
planCode: "—",
seats: { used: 0, total: 0 },
monthly: 0,
contact: "Klaus Altmann",
contactEmail: "klaus.altmann@altmann.de",
renewal: "—",
since: "2021-02-10",
entitled: [],
trialing: [],
seed: 70,
findingCount: 4,
people: [["Klaus Altmann", "klaus.altmann", ["IT_ADMIN"]]],
}),
sandbox: buildTenant({
id: "sandbox",
domain: "sandbox.breakpilot.eu",
name: "Breakpilot Sandbox",
short: "Sandbox",
mono: "SB",
status: "demo",
legalType: "—",
city: "Shared tenant",
country: "—",
vat: "—",
plan: "Demo",
planCode: "BP-DEMO",
seats: { used: 1, total: 99 },
monthly: 0,
contact: "Breakpilot",
contactEmail: "support@breakpilot.eu",
renewal: "—",
since: "—",
entitled: ["compliance-scanner", "certifai"],
trialing: [],
seed: 5151,
findingCount: 8,
people: [
["Sandbox Guest", "guest", ["USER", "IT_ADMIN"]],
["Demo Operator", "operator", ["CXO"]],
],
}),
};
// ---- sign-in fixtures (the 5 + demo) -----------------------------------
export type SignInFixture = {
id: string;
email: string;
tenant: string;
name: string;
roles: OrgRole[];
showcase: string;
};
export const FIXTURES: SignInFixture[] = [
{
id: "admin-acme",
email: "admin@acme",
tenant: "acme",
name: "Lena Brandt",
roles: ["IT_ADMIN", "CXO"],
showcase: "Full admin — every screen, all controls live.",
},
{
id: "user-acme",
email: "user@acme",
tenant: "acme",
name: "Tomas Vogel",
roles: ["USER"],
showcase: "Restricted — only assigned products, no settings.",
},
{
id: "trial-hello",
email: "trial@hello",
tenant: "hello",
name: "Marie Keller",
roles: ["IT_ADMIN"],
showcase: "Trial chrome — countdown banner + upgrade CTA.",
},
{
id: "frozen-globex",
email: "frozen@globex",
tenant: "globex",
name: "Stefan Huber",
roles: ["IT_ADMIN"],
showcase: "Frozen — read-only banner, 402 on writes.",
},
{
id: "archived-oldco",
email: "archived@oldco",
tenant: "oldco",
name: "Klaus Altmann",
roles: ["IT_ADMIN"],
showcase: "Archived — full-page lockout + export.",
},
{
id: "demo-sandbox",
email: "guest@sandbox",
tenant: "sandbox",
name: "Sandbox Guest",
roles: ["USER", "IT_ADMIN"],
showcase: "Demo sandbox — watermark on every page.",
},
];
// ---- routes / RBAC -----------------------------------------------------
export type RouteKey =
| "dashboard"
| "products"
| "workflows"
| "org"
| "team"
| "billing"
| "audit"
| "sso";
export const ROUTES: Record<RouteKey, { label: string; roles: OrgRole[] }> = {
dashboard: { label: "Overview", roles: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"] },
products: { label: "Products", roles: ["IT_ADMIN", "CXO", "USER"] },
workflows: { label: "Workflows", roles: ["IT_ADMIN"] },
org: { label: "Organization", roles: ["IT_ADMIN"] },
team: { label: "Team", roles: ["IT_ADMIN"] },
billing: { label: "Billing", roles: ["IT_ADMIN", "CXO", "FINANCE"] },
audit: { label: "Audit log", roles: ["IT_ADMIN", "LEGAL"] },
sso: { label: "SSO", roles: ["IT_ADMIN"] },
};
const DEFAULT_LANDING: Record<OrgRole, RouteKey> = {
IT_ADMIN: "dashboard",
CXO: "dashboard",
FINANCE: "billing",
LEGAL: "audit",
USER: "dashboard",
};
export function landingFor(roles: OrgRole[]): RouteKey {
for (const r of ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"] as OrgRole[]) {
if (roles.includes(r)) return DEFAULT_LANDING[r];
}
return "dashboard";
}
export function canAccess(roles: OrgRole[], route: RouteKey): boolean {
const def = ROUTES[route];
if (!def) return false;
return roles.some((r) => def.roles.includes(r));
}
export function tenantById(id: string): TenantRecord | undefined {
return TENANTS[id];
}
export function tenantBySlug(slug: string): TenantRecord | undefined {
return TENANTS[slug];
}
+49
View File
@@ -0,0 +1,49 @@
// Server-side session resolver for portal pages.
//
// Wraps `auth()` from src/auth.ts. In dev — when Keycloak isn't running —
// you can set `BP_DEV_FIXTURE=<fixture-id>` and the wrapper returns a
// synthetic SessionWithExtras built from the matching fixture in
// src/lib/fixtures.ts. Same shape Auth.js v5 would produce.
//
// Valid fixture ids: admin-acme | user-acme | trial-hello | frozen-globex
// | archived-oldco | demo-sandbox (see FIXTURES in fixtures.ts).
//
// Used by every server component that calls `auth()` for portal chrome,
// NOT by the real Auth.js handlers (those stay wired to Keycloak).
import { auth } from "@/auth";
import type { SessionWithExtras } from "@/lib/session";
import { FIXTURES, TENANTS } from "@/lib/fixtures";
function fixtureSession(id: string): SessionWithExtras | null {
const fx = FIXTURES.find((f) => f.id === id);
if (!fx) return null;
const t = TENANTS[fx.tenant];
if (!t) return null;
return {
user: { name: fx.name, email: fx.email, image: null },
expires: new Date(Date.now() + 8 * 3600 * 1000).toISOString(),
tenant_id: t.id,
tenant_slug: t.id,
org_roles: fx.roles,
products: [...t.entitled, ...t.trialing],
// Plan in fixtures is a display string; map down to the canonical
// enum that consumers expect.
plan:
t.plan.toLowerCase().includes("trial")
? "starter"
: t.plan.toLowerCase().includes("scale")
? "professional"
: "starter",
tenant_status: t.status,
} as SessionWithExtras;
}
export async function getPortalSession(): Promise<SessionWithExtras | null> {
const devId = process.env.BP_DEV_FIXTURE;
if (devId) {
const s = fixtureSession(devId);
if (s) return s;
}
return (await auth()) as SessionWithExtras | null;
}
+96
View File
@@ -0,0 +1,96 @@
// 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;
}