feat(portal): M10.2 design system foundations — tokens, shell, Dashboard
Brings in the handoff design system from `Breakpilot Platform.zip` (`breakpilot/design_handoff_customer_portal/`) as the base for restyling every customer-area surface. What's in: * **Design tokens & layout primitives** — `src/app/globals.css` is the handoff `styles.css` in full (OKLCH paper + ink + brand-violet, --rule-* hairlines, --sev-* severity ramp, corner-tick bracket treatment, ledger table, 32–36px row density, dark mode via `[data-theme="dark"]`). Tailwind v4 layered on top via PostCSS for utility helpers; the design system itself stays in plain CSS. * **Geist + Geist Mono** wired through `next/font/google` so the monospaced metadata/figures everywhere render at the intended weight. * **Shell chrome** under `src/components/portal/`: `Brand` (Breakpilot. wordmark with the violet trailing dot), `Lifeline` (top full-width tenant rail — active / trial / frozen / demo variants; archived swaps in `ArchivedLockout`), `NavRail` (232px left rail with tenant switcher + workspace/admin/ settings groups + user chip; locked routes show a lock icon and a "Requires X" tooltip rather than vanishing), `Topbar` (breadcrumb + ⌘K button placeholder + theme toggle), `ThemeToggle` (Sun/Moon, persists to `localStorage["bp.theme"]`, no-flash via a head script in the root layout). * **Dashboard** at `/[slug]/dashboard` rebuilt per handoff §1: page-head with Export + Run scan (the latter wrapped in the frozen write-guard hovercard surfacing `HTTP 402 · payment required`), 5-cell bracketed KPI rail (open findings + 14-day sparkbars + 7-day delta, critical with severity stack, controls passing with violet ring gauge + n/240, evidence area sparkline, last-scan cadence), 12-col grid: 30-day findings flow + severity stack legend + top-5 open findings table on the left, product posture rows + scan-activity heatmap (5x7) + recent-activity feed on the right. Plain USER role drops the KPI rail and the org-wide panels per spec. * **Charts** — minimal SVG primitives in `components/portal/charts/`: Sparkbars, Sparkline (area + line), Ring, StackBar, Heatmap + HeatLegend. All token-driven (`var(--sev-*)`, `var(--accent)`). * **Fixtures** — `src/lib/fixtures.ts` is a TS port of the handoff's `data.js`. Deterministic mulberry32 generators give the same realistic DACH/EU compliance data every reload (~5 tenants × 30+ days activity / 4–13 findings per product / 9 months invoices / hash- chained audit). Source of truth for the design until tenant-registry is enriched to carry these fields end-to-end. RBAC table (`canAccess`, `landingFor`) ported alongside. * **Dev session bypass** — `src/lib/get-session.ts` returns a synthetic `SessionWithExtras` from one of the 6 fixtures when `BP_DEV_FIXTURE=<id>` is set. Lets the portal render the design without Keycloak + tenant-registry up. Real Auth.js wiring untouched. What's NOT in yet (next commits): * Products / Product launch / Org / Team / Billing / Audit / SSO pages * Workflows editor (palette + canvas + inspector + drag-wiring) * Command palette + toast system * MSW handlers for the tenant data shapes (today the page reads the fixture module directly server-side; MSW is for client-side calls) Run locally: pnpm install BP_DEV_FIXTURE=admin-acme pnpm dev open http://acme.localhost:3000/acme/dashboard Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e387b9a963
commit
f3c95123fa
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user