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
sharang e387b9a963 feat(portal): M10.1 — fill the 10 customer-area shells
ci / shared (push) Successful in 8s
ci / test (push) Successful in 25s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
Four real surfaces wired to tenant-registry (settings, settings/api-keys CRUD, audit pagination, products live entitlements), five forward-looking empty states with CTAs. 56 vitest tests + 10 Playwright canaries. lib/format.ts consolidates date helpers.

Refs: M10.1
2026-05-20 07:20:31 +00:00
sharang ecbe6ae74b feat(portal): M11.1 catalog flow + M12.1 self-serve trial
ci / shared (push) Successful in 5s
ci / test (push) Successful in 26s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
Closes the customer loop: /start signup → tenant + KC org + IT_ADMIN invite → portal dashboard with trial banner → /[slug]/catalog with Request + Start trial server actions wired to tenant-registry.

Refs: M11.1 + M12.1
2026-05-19 16:27:10 +00:00
sharang 8ab82c8b37 docs(dev): pin AUTH_URL to the tenant subdomain
ci / test (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
ci / shared (push) Successful in 4s
Capture the redirect_uri gotcha from the live-stack smoke. .env.example pins AUTH_URL to acme.localhost:3000 with a long-form comment; README gets an 'AUTH_URL gotcha' callout.

Refs: M5.1 follow-up
2026-05-19 16:05:45 +00:00
sharang 3310a942f2 fix(test): assert real shipped behaviour for signed-out /products
ci / shared (push) Successful in 5s
ci / test (push) Successful in 33s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
Caught during live local-smoke run.

Refs: M4.2/M5.3
2026-05-19 15:09:01 +00:00
sharang 99fe3b55b2 feat(test): M5.3 — Playwright e2e harness for the dev stack
ci / shared (push) Successful in 4s
ci / test (push) Successful in 23s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
playwright.config.ts + tests/e2e/{apex,tenant,health}.spec.ts. make e2e for local. CI e2e job opt-in via RUN_E2E repo variable. OIDC click-through deferred to when stage is up.

Refs: M5.3
2026-05-19 14:53:18 +00:00
sharang fe139332ee feat(app): M5.2 — customer-area route shells + role-gated nav
ci / shared (push) Successful in 4s
ci / test (push) Successful in 29s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
10 route shells under /[slug]/, role-filtered Nav, backstage stub at /__backstage__, dashboard reads session.products to render tiles. src/lib/session.ts is the canonical role × surface matrix; canSee() is the only RBAC primitive in the portal (real enforcement remains at the API layer). 24 vitest tests; 100% src/lib coverage.

Refs: M5.2
2026-05-19 14:47:15 +00:00
58 changed files with 6108 additions and 184 deletions
+16 -3
View File
@@ -12,8 +12,21 @@ KEYCLOAK_CLIENT_ID=dev-portal
KEYCLOAK_CLIENT_SECRET=unused-public-client KEYCLOAK_CLIENT_SECRET=unused-public-client
# Auth.js v5 — required for JWT signing. # Auth.js v5 — required for JWT signing.
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32 (keep stable across restarts or
# every dev login invalidates the existing session).
AUTH_SECRET=dev-secret-change-me-do-not-ship-replace-with-32-byte-random AUTH_SECRET=dev-secret-change-me-do-not-ship-replace-with-32-byte-random
AUTH_URL=http://localhost:3000
# In prod we'd set AUTH_TRUST_HOST=true behind orca-proxy; dev is loopback so leave unset. # IMPORTANT: AUTH_URL must match the exact subdomain you're using in the
# browser. Auth.js v5 builds the OAuth redirect_uri from this value (NOT
# from the request Host header, even with AUTH_TRUST_HOST=true). If you
# visit http://acme.localhost:3000 but AUTH_URL is http://localhost:3000,
# the PKCE cookie set on acme.localhost won't be readable at the localhost
# callback, and Keycloak rejects the token exchange with
# 'invalid_grant: Incorrect redirect_uri'.
#
# For a single tenant dev flow, pin AUTH_URL to the subdomain you use:
AUTH_URL=http://acme.localhost:3000
# AUTH_TRUST_HOST is on so this still works behind orca-proxy in stage/prod
# (where the actual host is known via X-Forwarded-Host).
AUTH_TRUST_HOST=true
+7 -2
View File
@@ -87,7 +87,11 @@ jobs:
e2e: e2e:
needs: test needs: test
runs-on: docker runs-on: docker
if: hashFiles('playwright.config.ts','playwright.config.js') != '' && github.event_name == 'push' && github.ref == 'refs/heads/main' # Two gates: playwright.config.ts must exist + the repo variable
# RUN_E2E must be 'true'. Until stage.breakpilot.com is up (M1.2 +
# M0.3), the e2e job is opt-in. Locally, devs run `make e2e` against
# their own dev stack.
if: hashFiles('playwright.config.ts','playwright.config.js') != '' && github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.RUN_E2E == 'true'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -98,7 +102,8 @@ jobs:
- run: pnpm exec playwright install --with-deps chromium - run: pnpm exec playwright install --with-deps chromium
- run: pnpm e2e - run: pnpm e2e
env: env:
PLAYWRIGHT_BASE_URL: https://stage.breakpilot.com PLAYWRIGHT_BASE_URL: ${{ vars.STAGE_PORTAL_BASE_URL }}
PLAYWRIGHT_APEX_URL: ${{ vars.STAGE_PORTAL_APEX_URL }}
PLAYWRIGHT_TEST_USER: ${{ secrets.STAGE_TEST_USER }} PLAYWRIGHT_TEST_USER: ${{ secrets.STAGE_TEST_USER }}
PLAYWRIGHT_TEST_PASS: ${{ secrets.STAGE_TEST_PASS }} PLAYWRIGHT_TEST_PASS: ${{ secrets.STAGE_TEST_PASS }}
+4
View File
@@ -57,3 +57,7 @@ coverage/
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
# Playwright
playwright-report/
test-results/
+5
View File
@@ -6,6 +6,11 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased] ## [Unreleased]
### Added ### Added
- feat(portal): M10.1 — real content for /settings + /settings/api-keys (full CRUD) + /audit (paginated, filterable) + /products (live entitlements). Forward-looking empty states with milestone hooks + CTAs on projects/users/integrations/billing/support.
- feat(signup): M12.1 — public /start form creates a trial tenant via POST /v1/tenants (KC adapter provisions the org + invites the admin); dashboard renders a trial-days-left banner when status=trial
- feat(catalog): M11.1 — /[slug]/catalog renders the live catalog, gates owned products, server-action 'Request' (POST /v1/catalog/request) + 'Start 14-day trial' (POST /v1/catalog/trial-request)
- feat(test): M5.3 — Playwright e2e harness (apex / tenant / dev-stack-health specs). pnpm e2e + make e2e. CI e2e job gated behind RUN_E2E variable until stage exists.
- feat(app): M5.2 — customer-area route shells (settings, billing, audit, support, catalog, products, projects, settings/{users,api-keys,integrations}); shared Nav component reads session.org_roles and shows only what each role can see; backstage stub at /__backstage__; dashboard renders product tiles from session.products
- chore(deps): bump next + eslint-config-next to 16.2.6 to clear trivy CVEs (CVE-2025-29927 critical + 7 highs in next 15.0.3) - chore(deps): bump next + eslint-config-next to 16.2.6 to clear trivy CVEs (CVE-2025-29927 critical + 7 highs in next 15.0.3)
- feat(app): Next.js 16 + Auth.js v5 skeleton with host→slug middleware, tenant context layout, OIDC sign-in flow - feat(app): Next.js 16 + Auth.js v5 skeleton with host→slug middleware, tenant context layout, OIDC sign-in flow
- -
+15 -7
View File
@@ -4,13 +4,15 @@
help: help:
@echo "portal targets:" @echo "portal targets:"
@echo " make install pnpm install" @echo " make install pnpm install"
@echo " make dev pnpm dev (http://localhost:3000)" @echo " make dev pnpm dev (http://localhost:3000)"
@echo " make test pnpm test (vitest + coverage)" @echo " make test pnpm test (vitest + coverage)"
@echo " make lint pnpm lint" @echo " make lint pnpm lint"
@echo " make typecheck pnpm typecheck" @echo " make typecheck pnpm typecheck"
@echo " make build pnpm build (Next.js production build)" @echo " make build pnpm build (Next.js production build)"
@echo " make docker build local image (portal:dev)" @echo " make e2e pnpm e2e (Playwright; needs dev stack + tenant-registry + portal running)"
@echo " make e2e-install one-time browser install"
@echo " make docker build local image (portal:dev)"
install: install:
@pnpm install --frozen-lockfile @pnpm install --frozen-lockfile
@@ -30,6 +32,12 @@ typecheck:
build: build:
@pnpm build @pnpm build
e2e:
@pnpm e2e
e2e-install:
@pnpm e2e:install
docker: docker:
@docker build -t portal:dev . @docker build -t portal:dev .
+27
View File
@@ -38,6 +38,8 @@ make dev # next dev on http://localhost:3000
Seed login (from the dev-stack realm): `test@breakpilot.dev` / `test`. Seed login (from the dev-stack realm): `test@breakpilot.dev` / `test`.
> **AUTH_URL gotcha:** Auth.js v5 builds the OAuth `redirect_uri` from `AUTH_URL` — not from the request Host header, even with `AUTH_TRUST_HOST=true`. For multi-tenant dev work, pin `AUTH_URL` to the subdomain you log in on (e.g., `http://acme.localhost:3000`); otherwise Keycloak rejects the token exchange with `invalid_grant: Incorrect redirect_uri`. In prod, orca-proxy passes the right host via `X-Forwarded-Host` and `AUTH_URL` is set to the apex (`https://breakpilot.com`).
`make test` / `make lint` / `make typecheck` / `make build` run vitest / eslint / tsc / next build respectively. `make test` / `make lint` / `make typecheck` / `make build` run vitest / eslint / tsc / next build respectively.
Env vars live in `.env.example`. Copy to `.env.local` for local overrides (gitignored). Env vars live in `.env.example`. Copy to `.env.local` for local overrides (gitignored).
@@ -78,6 +80,31 @@ Rollback: `orca rollout undo portal --env={{env}}`.
See [`CONTRIBUTING.md`](./CONTRIBUTING.md). TL;DR: branch from main, open a PR, 1 review + green CI, squash-merge. See [`CONTRIBUTING.md`](./CONTRIBUTING.md). TL;DR: branch from main, open a PR, 1 review + green CI, squash-merge.
## End-to-end tests (M5.3)
Playwright config at `playwright.config.ts`. Tests under `tests/e2e/`.
```bash
make e2e-install # one-time: pnpm exec playwright install chromium
# bring up the dev stack + tenant-registry + portal in three separate terminals,
# then:
make e2e # pnpm playwright test
```
Test groups (filter with `--grep`):
| File | What it asserts |
|---|---|
| `tests/e2e/apex.spec.ts` | Apex landing page renders |
| `tests/e2e/tenant.spec.ts` | Tenant subdomain serves signed-out dashboard + 404 on unknown slug |
| `tests/e2e/health.spec.ts` | The whole dev stack is reachable: portal API, tenant-registry, Keycloak |
`@needs-stack` in a test title means the dev stack must be running. We don't yet have a full OIDC click-through test — Keycloak in headless mode is flaky, so we assert the gate (Sign-in button visible) rather than completing the login.
In CI, the e2e job is gated behind the repo variable `RUN_E2E == 'true'` so it stays off until stage exists. Lint / typecheck / build / vitest still run on every PR.
## License ## License
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE). Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
+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.
+14 -2
View File
@@ -14,21 +14,33 @@
"start": "next start --port 3000", "start": "next start --port 3000",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --coverage" "test": "vitest run --coverage",
"e2e": "playwright test",
"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",
"@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"
} }
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig, devices } from "@playwright/test";
// Playwright e2e harness for the portal.
//
// Local: assumes the full dev stack is running —
// 1. cd platform/orca-platform && make dev-up
// 2. cd platform/tenant-registry && make dev
// 3. cd platform/portal && make dev
// 4. cd platform/portal && pnpm e2e
//
// Defaults to http://acme.localhost:3000 so subdomain routing fires.
// Override with PLAYWRIGHT_BASE_URL for stage (once that exists, M5.x+).
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://acme.localhost:3000";
const apexURL = process.env.PLAYWRIGHT_APEX_URL ?? "http://localhost:3000";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false, // Hosts share subdomains; serial keeps logs sane.
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
// Tag-friendly: tests that need the dev stack alive use `@needs-stack`
// in their title so we can split fast/slow runs in CI later.
metadata: {
apexURL,
baseURL,
},
});
+811 -73
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;
+101
View File
@@ -0,0 +1,101 @@
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";
const EVENT_FILTERS = ["all", "auth", "scan", "finding", "evidence", "billing", "settings"];
export default async function AuditPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ q?: string; type?: string; product?: string }>;
}) {
const { slug } = await params;
const sp = await searchParams;
const session = await getPortalSession();
if (!canSee(session, "audit")) return <NotAllowed need="LEGAL / IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const q = sp.q?.toLowerCase() ?? "";
const type = sp.type ?? "all";
const product = sp.product ?? "all";
const rows = t.audit.filter((r) => {
if (q && !`${r.event} ${r.actor} ${r.product}`.toLowerCase().includes(q)) return false;
if (type !== "all" && !r.event.startsWith(type)) return false;
if (product !== "all" && r.product !== product) return false;
return true;
});
return (
<div className="content-inner">
<div className="page-head">
<div>
<div className="page-title">Audit log</div>
<div className="page-sub">
<span className="mono">{rows.length}</span> of <span className="mono">{t.audit.length}</span>{" "}
events · retention 365 days · hash-chained
</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Export (CSV)</button>
</div>
</div>
<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>
<table className="ltable">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Product</th>
<th>Source IP</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 50).map((r, i) => (
<tr key={i}>
<td className="mono t-dim" style={{ whiteSpace: "nowrap" }}>{r.date} {r.time}</td>
<td className="mono" style={{ fontSize: 11.5 }}>{r.event}</td>
<td>{r.actor}</td>
<td className="mono t-dim">{r.product}</td>
<td className="mono t-dim">{r.ip}</td>
<td>
<span className="row" style={{ gap: 6, fontSize: 12 }}>
<span className={`dot ${r.result === "denied" ? "danger" : "ok"}`} />
{r.result === "denied" ? "DENIED" : "OK"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Panel>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
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";
const EUR = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
export default async function BillingPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await getPortalSession();
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 (
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">Billing</div>
<div className="page-sub">Plan, usage, payment method and invoice history</div>
</div>
<div className="ph-actions">
<button type="button" className="btn">Download all (PDF)</button>
</div>
</div>
<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>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { NotAuthorized } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
import {
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
requestProduct,
startTrial,
type CatalogEntry,
} from "@/lib/tenant-registry";
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ ok?: string; err?: string }>;
}) {
const { slug } = await params;
const flash = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "catalog")) return <NotAuthorized />;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) redirect(`/${slug}/dashboard`);
const [catalog, entitlements] = await Promise.all([
fetchCatalog(),
fetchEntitlements(tenant.id),
]);
const enabled = new Set(entitlements.filter((e) => e.enabled).map((e) => e.product));
async function doRequest(formData: FormData) {
"use server";
const product = String(formData.get("product"));
const tenantId = String(formData.get("tenant_id"));
const slugV = String(formData.get("slug"));
const res = await requestProduct(tenantId, product);
const param = res.ok ? `ok=requested:${product}` : `err=${res.error}`;
redirect(`/${slugV}/catalog?${param}`);
}
async function doTrial(formData: FormData) {
"use server";
const product = String(formData.get("product"));
const tenantId = String(formData.get("tenant_id"));
const slugV = String(formData.get("slug"));
const res = await startTrial(tenantId, product);
const param = res.ok ? `ok=trial:${product}` : `err=${res.error}`;
revalidatePath(`/${slugV}/catalog`);
redirect(`/${slugV}/catalog?${param}`);
}
return (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Catalog</h1>
<p style={{ color: "#444", marginBottom: 16 }}>
Pick a product to add to your plan. Trial-eligible products start a
14-day evaluation; everything else opens a CRM lead for sales follow-up.
</p>
<FlashBanner ok={flash.ok} err={flash.err} />
<ul
style={{
listStyle: "none",
padding: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
marginTop: 16,
}}
>
{catalog.map((p) => (
<CatalogCard
key={p.key}
product={p}
owned={enabled.has(p.key)}
tenantId={tenant.id}
slug={slug}
doRequest={doRequest}
doTrial={doTrial}
/>
))}
</ul>
</section>
);
}
function FlashBanner({ ok, err }: { ok?: string; err?: string }) {
if (!ok && !err) return null;
const isOk = !!ok;
return (
<div
role="status"
style={{
padding: 12,
borderRadius: 8,
marginBottom: 12,
background: isOk ? "#e6f7ec" : "#fdecea",
color: isOk ? "#0a6e2a" : "#a82626",
border: `1px solid ${isOk ? "#a4d8b8" : "#e8a5a5"}`,
}}
>
{isOk ? `OK — ${ok}` : `Error — ${err}`}
</div>
);
}
function CatalogCard({
product,
owned,
tenantId,
slug,
doRequest,
doTrial,
}: {
product: CatalogEntry;
owned: boolean;
tenantId: string;
slug: string;
doRequest: (fd: FormData) => Promise<void>;
doTrial: (fd: FormData) => Promise<void>;
}) {
return (
<li
style={{
padding: 16,
border: "1px solid #eaeaea",
borderRadius: 8,
background: "white",
}}
>
<strong style={{ fontSize: 15 }}>{product.name}</strong>
<p style={{ color: "#666", fontSize: 13, marginTop: 4, marginBottom: 12 }}>
{product.description}
</p>
<div style={{ fontSize: 12, color: "#666", marginBottom: 12 }}>
Plans: {product.plans_required.join(", ")}
</div>
{owned ? (
<span
style={{
fontSize: 12,
padding: "4px 8px",
background: "#eef",
borderRadius: 4,
color: "#226",
}}
>
Active
</span>
) : (
<div style={{ display: "flex", gap: 8 }}>
<form action={doRequest}>
<input type="hidden" name="product" value={product.key} />
<input type="hidden" name="tenant_id" value={tenantId} />
<input type="hidden" name="slug" value={slug} />
<button
type="submit"
style={{
padding: "6px 10px",
background: "white",
color: "#0070f3",
border: "1px solid #0070f3",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
}}
>
Request
</button>
</form>
{product.supports_trial && (
<form action={doTrial}>
<input type="hidden" name="product" value={product.key} />
<input type="hidden" name="tenant_id" value={tenantId} />
<input type="hidden" name="slug" value={slug} />
<button
type="submit"
style={{
padding: "6px 10px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
}}
>
Start 14-day trial
</button>
</form>
)}
</div>
)}
</li>
);
}
+351 -21
View File
@@ -1,4 +1,24 @@
import { auth, signIn, signOut } from "@/auth"; import Link from "next/link";
import { ArrowRight, Download, Play, ShieldAlert } from "lucide-react";
import { signIn } from "@/auth";
import { getPortalSession } from "@/lib/get-session";
import { loadTenantForShell } from "@/lib/portal-data";
import { Panel } from "@/components/portal/Panel";
import { Monogram } from "@/components/portal/Monogram";
import { Sev } from "@/components/portal/Sev";
import { Sparkbars } from "@/components/portal/charts/Sparkbars";
import { Sparkline } from "@/components/portal/charts/Sparkline";
import { Ring } from "@/components/portal/charts/Ring";
import { StackBar } from "@/components/portal/charts/StackBar";
import { Heatmap, HeatLegend } from "@/components/portal/charts/Heatmap";
import { productById, type Severity } from "@/lib/fixtures";
const SEV_LABEL: Record<Severity, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export default async function Dashboard({ export default async function Dashboard({
params, params,
@@ -6,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(); const session = await getPortalSession();
const tenant = await loadTenantForShell(slug);
if (!tenant) return null;
if (!session) { if (!session) {
async function login() { async function login() {
@@ -14,31 +36,339 @@ export default async function Dashboard({
await signIn("keycloak", { redirectTo: `/${slug}/dashboard` }); await signIn("keycloak", { redirectTo: `/${slug}/dashboard` });
} }
return ( return (
<div> <div className="content-inner">
<h1>Sign in to {slug}</h1> <div className="page-head">
<form action={login}> <div>
<button type="submit">Sign in with Keycloak</button> <div className="page-title">Sign in to {tenant.name}</div>
</form> <div className="page-sub">
Authenticate via Keycloak to view the {tenant.short} control plane.
</div>
</div>
</div>
<Panel bracket>
<form action={login}>
<button type="submit" className="btn btn-primary">
Sign in with Keycloak
</button>
</form>
</Panel>
</div> </div>
); );
} }
async function logout() { const m = tenant.metrics;
"use server"; const userOnly =
await signOut({ redirectTo: `/${slug}/dashboard` }); (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 ( return (
<div> <div className="content-inner">
<h1>Dashboard</h1> <div className="page-head">
<p> <div>
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. This is the{" "} <div className="page-title">
<code>{slug}</code> dashboard. Real product tiles, settings, billing land {userOnly ? "Workspace" : "Overview"}
in M5.2 / M10.1. </div>
</p> <div className="page-sub">
<form action={logout} style={{ marginTop: 24 }}> {tenant.name} ·{" "}
<button type="submit">Sign out</button> <span className="mono">
</form> {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> </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>
);
}
+69 -19
View File
@@ -1,6 +1,11 @@
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { fetchTenantBySlug } from "@/lib/tenant-registry"; import { getPortalSession } from "@/lib/get-session";
import { loadTenantForShell } from "@/lib/portal-data";
import { Lifeline } from "@/components/portal/Lifeline";
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,
@@ -10,27 +15,72 @@ 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 getPortalSession();
// 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
// tenant_slug, redirect back to whatever this user CAN see.
if (session && session.tenant_slug && session.tenant_slug !== slug) {
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 (
<div className="app">
<div className="app-body">
<main className="main">
<div className="content">
<div className="content-inner">{children}</div>
</div>
</main>
</div>
</div>
);
}
return ( return (
<div> <div className="app">
<header <Lifeline
style={{ tenant={{
padding: "12px 24px", status: tenant.status,
borderBottom: "1px solid #eaeaea", slug,
background: "white", plan: tenant.plan,
display: "flex", seats: tenant.seats,
alignItems: "center", trialDaysLeft: tenant.trialDaysLeft,
justifyContent: "space-between", trialEnds: tenant.trialEnds,
frozenReason: tenant.frozenReason,
}} }}
> />
<strong>{tenant.name}</strong> <div className="app-body">
<span style={{ fontSize: 12, color: "#666" }}> <NavRail
{tenant.plan} · {tenant.status} slug={slug}
</span> tenant={{
</header> name: tenant.name,
<main style={{ padding: 24 }}>{children}</main> 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> </div>
); );
} }
+159
View File
@@ -0,0 +1,159 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { canSee } from "@/lib/session";
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 { NotAllowed } from "@/components/portal/NotAllowed";
export default async function ProductsPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ p?: string }>;
}) {
const { slug } = await params;
const sp = await searchParams;
const session = await getPortalSession();
if (!canSee(session, "products")) return <NotAllowed need="USER+" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const productFilter = sp.p ?? "all";
const findings = t.findings.filter((f) => productFilter === "all" || f.product === productFilter);
return (
<div className="content-inner">
<div className="page-head">
<div>
<div className="page-title">Products</div>
<div className="page-sub">
{t.entitled.length} entitled · click a product to open its launch screen
</div>
</div>
</div>
<div className="product-grid" style={{ marginBottom: 14 }}>
{t.products.map((p) => {
const isLive = p.status === "live";
const entitled = t.entitled.includes(p.id);
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));
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>
))}
</div>
<div className="pcard-stats">
<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>
<Panel
title="Findings across products"
tail={
<span className="row" style={{ gap: 6 }}>
{["all", "compliance-scanner", "certifai"].map((o) => (
<Link
key={o}
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
>
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
</Link>
))}
</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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
export default async function Page() {
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "projects")) return <NotAuthorized />;
return (
<ShellEmpty
title="Projects"
description="Sub-tenancy: scope products by team/environment (GCP-Project-style)."
milestone="M10.1 follow-up"
details="Most tenants operate as a single implicit 'default' project. Multi-project mode activates once a product opts in via manifest.supports_projects=true."
/>
);
}
+266
View File
@@ -0,0 +1,266 @@
import { revalidatePath } from "next/cache";
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 {
createAPIKey,
fetchAPIKeys,
fetchCatalog,
fetchTenantBySlug,
revokeAPIKey,
type APIKey,
} from "@/lib/tenant-registry";
export default async function APIKeysPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ plaintext?: string; err?: string }>;
}) {
const { slug } = await params;
const flash = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "api-keys")) return <NotAuthorized />;
const tenant = await fetchTenantBySlug(slug);
if (!tenant) redirect(`/${slug}/dashboard`);
const [keys, catalog] = await Promise.all([
fetchAPIKeys(tenant.id),
fetchCatalog(),
]);
const active = keys.filter((k) => !k.revoked_at);
const revoked = keys.filter((k) => k.revoked_at);
async function doCreate(formData: FormData) {
"use server";
const name = String(formData.get("name") ?? "").trim();
const product = String(formData.get("product") ?? "").trim();
const tenantId = String(formData.get("tenant_id"));
const slugV = String(formData.get("slug"));
if (!name) redirect(`/${slugV}/settings/api-keys?err=missing_name`);
const res = await createAPIKey({
tenant_id: tenantId,
name,
product: product || undefined,
});
if (!res.ok) {
redirect(`/${slugV}/settings/api-keys?err=${res.error}`);
}
revalidatePath(`/${slugV}/settings/api-keys`);
redirect(`/${slugV}/settings/api-keys?plaintext=${encodeURIComponent(res.plaintext)}`);
}
async function doRevoke(formData: FormData) {
"use server";
const id = String(formData.get("id"));
const slugV = String(formData.get("slug"));
const res = await revokeAPIKey(id);
if (!res.ok) {
redirect(`/${slugV}/settings/api-keys?err=${res.error}`);
}
revalidatePath(`/${slugV}/settings/api-keys`);
redirect(`/${slugV}/settings/api-keys`);
}
return (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>API keys</h1>
<p style={{ color: "#444", marginBottom: 16 }}>
Per-tenant keys for headless product calls. Hashed with argon2id;
the plaintext is shown <strong>once</strong> on creation.
</p>
{flash.plaintext && <PlaintextBanner plaintext={flash.plaintext} />}
{flash.err && <ErrorBanner err={flash.err} />}
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>Create a new key</h2>
<form action={doCreate} style={{ display: "grid", gap: 8, maxWidth: 480 }}>
<input type="hidden" name="tenant_id" value={tenant.id} />
<input type="hidden" name="slug" value={slug} />
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Name</span>
<input name="name" required maxLength={100} placeholder="ci-bot" style={inputStyle} />
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Product scope (optional)</span>
<select name="product" defaultValue="" style={inputStyle}>
<option value="">All products</option>
{catalog.map((p) => (
<option key={p.key} value={p.key}>
{p.name}
</option>
))}
</select>
</label>
<button type="submit" style={btnPrimary}>
Create key
</button>
</form>
<h2 style={{ fontSize: 18, marginTop: 32, marginBottom: 8 }}>
Active keys ({active.length})
</h2>
{active.length === 0 ? (
<p style={{ color: "#666", fontSize: 14 }}>No active keys.</p>
) : (
<KeyTable keys={active} doRevoke={doRevoke} slug={slug} canRevoke />
)}
{revoked.length > 0 && (
<>
<h2 style={{ fontSize: 18, marginTop: 32, marginBottom: 8 }}>
Revoked ({revoked.length})
</h2>
<KeyTable keys={revoked} doRevoke={doRevoke} slug={slug} canRevoke={false} />
</>
)}
</section>
);
}
function PlaintextBanner({ plaintext }: { plaintext: string }) {
return (
<div
role="status"
style={{
padding: 12,
marginTop: 8,
marginBottom: 8,
borderRadius: 8,
background: "#e6f7ec",
border: "1px solid #a4d8b8",
}}
>
<strong style={{ color: "#0a6e2a", fontSize: 14 }}>Key created</strong>
<p style={{ margin: "8px 0", fontSize: 13, color: "#444" }}>
Store this value it cannot be retrieved later.
</p>
<code
style={{
display: "block",
padding: "8px 10px",
background: "white",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 13,
wordBreak: "break-all",
}}
>
{plaintext}
</code>
</div>
);
}
function ErrorBanner({ err }: { err: string }) {
return (
<div
role="status"
style={{
padding: 12,
marginTop: 8,
marginBottom: 8,
borderRadius: 8,
background: "#fdecea",
border: "1px solid #e8a5a5",
color: "#a82626",
fontSize: 14,
}}
>
{err === "name_taken" && "A key with that name already exists."}
{err === "missing_name" && "Name is required."}
{err === "invalid_input" && "Input failed validation."}
{!["name_taken", "missing_name", "invalid_input"].includes(err) && `Error: ${err}`}
</div>
);
}
function KeyTable({
keys,
doRevoke,
slug,
canRevoke,
}: {
keys: APIKey[];
doRevoke: (fd: FormData) => Promise<void>;
slug: string;
canRevoke: boolean;
}) {
return (
<div style={{ overflow: "auto" }}>
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "1px solid #eaeaea" }}>
<th style={th}>Name</th>
<th style={th}>Prefix</th>
<th style={th}>Product</th>
<th style={th}>Created</th>
<th style={th}>Last used</th>
{canRevoke && <th style={th}></th>}
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={td}>{truncate(k.name, 30)}</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace" }}>{k.prefix}</td>
<td style={td}>{k.product || <em style={{ color: "#999" }}>all</em>}</td>
<td style={td} title={formatDateTime(k.created_at)}>
{formatRelative(k.created_at)}
</td>
<td style={td}>
{k.last_used_at ? formatRelative(k.last_used_at) : <em style={{ color: "#999" }}>never</em>}
</td>
{canRevoke && (
<td style={td}>
<form action={doRevoke}>
<input type="hidden" name="id" value={k.id} />
<input type="hidden" name="slug" value={slug} />
<button type="submit" style={btnDanger}>
Revoke
</button>
</form>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 14,
};
const btnPrimary: React.CSSProperties = {
marginTop: 4,
padding: "8px 14px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
justifySelf: "start",
};
const btnDanger: React.CSSProperties = {
padding: "4px 8px",
background: "white",
color: "#a82626",
border: "1px solid #e8a5a5",
borderRadius: 4,
fontSize: 12,
cursor: "pointer",
};
const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 };
const td: React.CSSProperties = { padding: "8px 10px" };
@@ -0,0 +1,76 @@
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"],
];
return (
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">SSO</div>
<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>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { canSee } from "@/lib/session";
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";
// "Organization" — IT_ADMIN only.
export default async function OrganizationPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await getPortalSession();
if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
const t = await loadTenantForShell(slug);
if (!t) return null;
const subscribed = t.products.filter((p) => t.entitled.includes(p.id));
const trialing = t.products.filter((p) => t.trialing.includes(p.id));
const seatsLeft = t.seats.total - t.seats.used;
const pct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
return (
<div className="content-inner" style={{ maxWidth: 1080 }}>
<div className="page-head">
<div>
<div className="page-title">Organization</div>
<div className="page-sub">
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>
<div className="grid g-12">
<div className="span-7 col" style={{ gap: 12 }}>
<Panel title="Legal entity" bracket>
<dl className="dl">
<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>
<Panel title="Primary contact">
<div className="row" style={{ gap: 12 }}>
<span className="avatar" style={{ width: 38, height: 38, fontSize: 13 }}>
{t.contact.split(" ").map((s) => s[0]).join("")}
</span>
<div>
<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 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>
);
}
+79
View File
@@ -0,0 +1,79 @@
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;
return (
<div className="content-inner">
<div className="page-head">
<div>
<div className="page-title">Team</div>
<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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { auth } from "@/auth";
import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
export default async function Page() {
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "support")) return <NotAuthorized />;
return (
<ShellEmpty
title="Support"
description="Submit a ticket — Frappe HD's customer portal embedded here."
milestone="M9.1"
details="Email oncall@breakpilot.com in the meantime. Tickets that need engineering attention escalate into Gitea issues automatically (M9.2)."
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { auth } from "@/auth";
import type { SessionWithExtras } from "@/lib/session";
// Backstage — platform-staff-only surface. The middleware rewrites
// http://backstage.localhost:3000/* → /__backstage__/* so this is
// reachable only via that hostname. Real RBAC (BREAKPILOT_ADMIN /
// SUPPORT_ENGINEER / SALES_REP) lands in M13.2.
export default async function Backstage() {
const session = (await auth()) as SessionWithExtras | null;
if (!session) {
return (
<section style={{ padding: 32 }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Backstage</h1>
<p>Sign in with a BREAKPILOT_ADMIN / SUPPORT_ENGINEER / SALES_REP account.</p>
</section>
);
}
return (
<section style={{ padding: 32 }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Backstage</h1>
<p>
Signed in as <code>{session.user?.email}</code>.
</p>
<p style={{ marginTop: 24, color: "#666" }}>
Tenants list, leads, demo console, impersonation all land in M13.2 / M14.x.
</p>
</section>
);
}
+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; }
+18 -12
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", <head>
color: "#111", {/* Restore the user's last theme before paint to avoid a flash. */}
}} <script
> dangerouslySetInnerHTML={{
{children} __html: `try{var t=localStorage.getItem("bp.theme");if(t==="dark"||t==="light")document.documentElement.setAttribute("data-theme",t);}catch(e){}`,
</body> }}
/>
</head>
<body>{children}</body>
</html> </html>
); );
} }
+138
View File
@@ -0,0 +1,138 @@
import { redirect } from "next/navigation";
import { createTenant } from "@/lib/tenant-registry";
// Public self-serve signup. Apex-level; no slug. Creates a trial tenant
// via tenant-registry, which also provisions a Keycloak organization +
// invites the user as IT_ADMIN. The portal middleware rewrites
// signup.<apex> here, but the bare path also works for dev.
//
// After success the user lands at /<slug>/dashboard. In prod they'd
// follow the KC invite email to set a password; in dev (no Stalwart yet)
// the invite_url is logged for the operator to share manually.
export default async function StartPage({
searchParams,
}: {
searchParams: Promise<{ err?: string }>;
}) {
const flash = await searchParams;
async function submit(formData: FormData) {
"use server";
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const plan =
(String(formData.get("plan") ?? "starter") as "starter" | "professional" | "enterprise") ||
"starter";
if (!slug || !name || !email) {
redirect("/start?err=missing_fields");
}
const res = await createTenant({
slug,
name,
plan,
admin_email: email,
});
if (!res.ok) {
redirect(`/start?err=${res.error}`);
}
redirect(`/${res.tenant.slug}/dashboard?ok=created`);
}
return (
<main style={{ maxWidth: 480, margin: "10vh auto", padding: "0 24px" }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Start a 14-day trial</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Spin up your tenant. You&apos;ll get an email invite from Keycloak with
a link to set your password.
</p>
{flash.err && (
<div
role="status"
style={{
padding: 12,
borderRadius: 8,
marginBottom: 16,
background: "#fdecea",
color: "#a82626",
border: "1px solid #e8a5a5",
fontSize: 14,
}}
>
{flash.err === "slug_taken" && "That slug is already in use. Pick another."}
{flash.err === "invalid_input" && "Slug must be 3+ chars, lowercase letters / digits / hyphens."}
{flash.err === "missing_fields" && "All fields are required."}
{!["slug_taken", "invalid_input", "missing_fields"].includes(flash.err) &&
`Something broke: ${flash.err}`}
</div>
)}
<form action={submit} style={{ display: "grid", gap: 12 }}>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Tenant slug</span>
<input
name="slug"
required
placeholder="acme"
pattern="^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$"
style={inputStyle}
/>
<small style={{ color: "#666" }}>
Becomes <code>&lt;slug&gt;.breakpilot.com</code>. Lowercase, hyphens allowed.
</small>
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Company name</span>
<input name="name" required placeholder="Acme Inc." style={inputStyle} />
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Admin email</span>
<input
name="email"
type="email"
required
placeholder="you@acme.test"
style={inputStyle}
/>
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Plan</span>
<select name="plan" defaultValue="starter" style={inputStyle}>
<option value="starter">Starter</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<button
type="submit"
style={{
marginTop: 8,
padding: "10px 16px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Start trial
</button>
</form>
</main>
);
}
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 14,
};
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import type { Surface, SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
type NavLink = {
href: string;
label: string;
surface: Surface;
};
export function Nav({ slug, session }: { slug: string; session: SessionWithExtras }) {
const links: NavLink[] = [
{ href: `/${slug}/dashboard`, label: "Dashboard", surface: "dashboard" },
{ href: `/${slug}/products`, label: "Products", surface: "products" },
{ href: `/${slug}/catalog`, label: "Catalog", surface: "catalog" },
{ href: `/${slug}/projects`, label: "Projects", surface: "projects" },
{ href: `/${slug}/settings`, label: "Settings", surface: "settings" },
{ href: `/${slug}/settings/users`, label: "Users", surface: "users" },
{ href: `/${slug}/settings/api-keys`, label: "API keys", surface: "api-keys" },
{ href: `/${slug}/settings/integrations`, label: "Integrations", surface: "integrations" },
{ href: `/${slug}/billing`, label: "Billing", surface: "billing" },
{ href: `/${slug}/audit`, label: "Audit log", surface: "audit" },
{ href: `/${slug}/support`, label: "Support", surface: "support" },
];
const visible = links.filter((l) => canSee(session, l.surface));
return (
<nav style={{ width: 220, padding: 16, borderRight: "1px solid #eaeaea", background: "white" }}>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{visible.map((l) => (
<li key={l.href} style={{ margin: "8px 0" }}>
<Link href={l.href} style={{ color: "#0070f3", textDecoration: "none" }}>
{l.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
+63
View File
@@ -0,0 +1,63 @@
// Empty state for surfaces whose real backend hasn't shipped yet.
// `milestone` names the milestone that unblocks the surface; `cta` is an
// optional in-portal action (link or button) the user can take in the
// meantime (e.g., "Browse the catalog" while real billing waits on M8.3).
import type { ReactNode } from "react";
export function ShellEmpty({
title,
description,
milestone,
details,
cta,
}: {
title: string;
description: string;
milestone: string;
details?: string;
cta?: ReactNode;
}) {
return (
<section style={{ maxWidth: 720 }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>{title}</h1>
<p style={{ color: "#444", marginBottom: 24 }}>{description}</p>
<div
style={{
padding: 16,
border: "1px dashed #ddd",
borderRadius: 8,
background: "#fafafa",
color: "#666",
fontSize: 14,
}}
>
<div style={{ marginBottom: details ? 8 : 0 }}>
Lands in <code>{milestone}</code>. See{" "}
<a
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PLATFORM_ARCHITECTURE.md"
style={{ color: "#0070f3" }}
>
PLATFORM_ARCHITECTURE.md §5a
</a>
.
</div>
{details && <p style={{ marginTop: 8, marginBottom: 0 }}>{details}</p>}
{cta && <div style={{ marginTop: 12 }}>{cta}</div>}
</div>
</section>
);
}
export function NotAuthorized() {
return (
<section style={{ maxWidth: 720 }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>403 Not authorized</h1>
<p style={{ color: "#444" }}>
This surface requires a role your account doesn&apos;t have. If you think
that&apos;s a mistake, ask an IT_ADMIN on your tenant to invite you with
the right role.
</p>
</section>
);
}
+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];
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import { formatDateTime, formatRelative, truncate } from "./format";
describe("formatRelative", () => {
const NOW = new Date("2026-05-20T12:00:00Z").getTime();
test("seconds ago", () => {
expect(formatRelative("2026-05-20T11:59:50Z", NOW)).toBe("10 seconds ago");
});
test("singular unit", () => {
expect(formatRelative("2026-05-20T11:59:59Z", NOW)).toBe("1 second ago");
});
test("minutes ago", () => {
expect(formatRelative("2026-05-20T11:55:00Z", NOW)).toBe("5 minutes ago");
});
test("hours ago", () => {
expect(formatRelative("2026-05-20T09:00:00Z", NOW)).toBe("3 hours ago");
});
test("days ago", () => {
expect(formatRelative("2026-05-13T12:00:00Z", NOW)).toBe("1 week ago");
});
test("future", () => {
expect(formatRelative("2026-06-03T12:00:00Z", NOW)).toBe("in 2 weeks");
});
test("malformed input returns the input", () => {
expect(formatRelative("not-a-date", NOW)).toBe("not-a-date");
});
test("years ago", () => {
expect(formatRelative("2024-05-20T12:00:00Z", NOW)).toBe("2 years ago");
});
test("months ago", () => {
expect(formatRelative("2026-01-20T12:00:00Z", NOW)).toBe("4 months ago");
});
});
describe("formatDateTime", () => {
test("formats UTC", () => {
expect(formatDateTime("2026-05-20T12:34:56Z")).toBe("2026-05-20 12:34:56 UTC");
});
test("malformed input returns input", () => {
expect(formatDateTime("nope")).toBe("nope");
});
});
describe("truncate", () => {
test("short string unchanged", () => {
expect(truncate("hi", 10)).toBe("hi");
});
test("long string truncated with ellipsis", () => {
expect(truncate("abcdefghij", 6)).toBe("abcde…");
});
test("default max", () => {
expect(truncate("x".repeat(50)).length).toBe(40);
});
});
+56
View File
@@ -0,0 +1,56 @@
// Lightweight date/time helpers — shared across server components so we
// don't reinvent toLocaleString conventions per page.
export function formatRelative(iso: string, now: number = Date.now()): string {
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const diff = t - now;
const abs = Math.abs(diff);
const ago = diff < 0;
const units: [string, number][] = [
["second", 1000],
["minute", 60 * 1000],
["hour", 3600 * 1000],
["day", 24 * 3600 * 1000],
["week", 7 * 24 * 3600 * 1000],
["month", 30 * 24 * 3600 * 1000],
["year", 365 * 24 * 3600 * 1000],
];
let unit = "second";
let n = Math.round(abs / 1000);
for (let i = units.length - 1; i >= 0; i--) {
if (abs >= units[i][1]) {
unit = units[i][0];
n = Math.round(abs / units[i][1]);
break;
}
}
const suffix = n === 1 ? unit : `${unit}s`;
return ago ? `${n} ${suffix} ago` : `in ${n} ${suffix}`;
}
export function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
// YYYY-MM-DD HH:MM:SS — locale-stable, sortable, no surprises.
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getUTCFullYear() +
"-" +
pad(d.getUTCMonth() + 1) +
"-" +
pad(d.getUTCDate()) +
" " +
pad(d.getUTCHours()) +
":" +
pad(d.getUTCMinutes()) +
":" +
pad(d.getUTCSeconds()) +
" UTC"
);
}
export function truncate(s: string, max = 40): string {
if (s.length <= max) return s;
return s.slice(0, max - 1) + "…";
}
+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;
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { canSee, hasAnyOrgRole, hasOrgRole, hasProduct } from "./session";
import type { SessionWithExtras } from "./session";
function s(roles: SessionWithExtras["org_roles"], products: string[] = []): SessionWithExtras {
return {
user: { name: "Test", email: "t@x.test" },
expires: "2099-01-01T00:00:00Z",
org_roles: roles,
products,
};
}
describe("hasOrgRole", () => {
test("null session has no roles", () => {
expect(hasOrgRole(null, "IT_ADMIN")).toBe(false);
});
test("matches single role", () => {
expect(hasOrgRole(s(["CXO"]), "CXO")).toBe(true);
expect(hasOrgRole(s(["CXO"]), "IT_ADMIN")).toBe(false);
});
});
describe("hasAnyOrgRole", () => {
test("any match wins", () => {
expect(hasAnyOrgRole(s(["LEGAL"]), ["IT_ADMIN", "LEGAL"])).toBe(true);
expect(hasAnyOrgRole(s(["USER"]), ["IT_ADMIN", "CXO"])).toBe(false);
});
test("empty roles", () => {
expect(hasAnyOrgRole(s(undefined), ["IT_ADMIN"])).toBe(false);
});
});
describe("hasProduct", () => {
test("checks products array", () => {
expect(hasProduct(s(["USER"], ["certifai"]), "certifai")).toBe(true);
expect(hasProduct(s(["USER"], ["certifai"]), "compliance")).toBe(false);
expect(hasProduct(null, "certifai")).toBe(false);
});
});
describe("canSee", () => {
test("IT_ADMIN sees settings, USER does not", () => {
expect(canSee(s(["IT_ADMIN"]), "settings")).toBe(true);
expect(canSee(s(["USER"]), "settings")).toBe(false);
});
test("CXO can see billing", () => {
expect(canSee(s(["CXO"]), "billing")).toBe(true);
});
test("LEGAL can see audit but not settings", () => {
expect(canSee(s(["LEGAL"]), "audit")).toBe(true);
expect(canSee(s(["LEGAL"]), "settings")).toBe(false);
});
test("FINANCE sees billing but not settings", () => {
expect(canSee(s(["FINANCE"]), "billing")).toBe(true);
expect(canSee(s(["FINANCE"]), "settings")).toBe(false);
});
test("dashboard visible to everyone with any role", () => {
expect(canSee(s(["USER"]), "dashboard")).toBe(true);
expect(canSee(s(["LEGAL"]), "dashboard")).toBe(true);
});
test("null session sees nothing", () => {
expect(canSee(null, "dashboard")).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
// Session-derived types & helpers — keep all session-shape knowledge in
// one place so route components don't all repeat the same casts.
//
// The breakpilot-dev realm projects these claims into every JWT via
// protocol mappers (see platform/orca-platform/dev/keycloak/realm-export.json).
// Auth.js v5 callbacks copy them onto the session in src/auth.ts.
import type { Session } from "next-auth";
export type OrgRole = "IT_ADMIN" | "CXO" | "FINANCE" | "LEGAL" | "USER";
export type TenantStatus = "demo" | "trial" | "active" | "frozen" | "archived";
export type Plan = "starter" | "professional" | "enterprise";
export type SessionExtras = {
tenant_id?: string;
tenant_slug?: string;
org_roles?: OrgRole[];
products?: string[];
plan?: Plan;
tenant_status?: TenantStatus;
};
export type SessionWithExtras = Session & SessionExtras;
export function hasOrgRole(s: SessionWithExtras | null, role: OrgRole): boolean {
return !!s?.org_roles?.includes(role);
}
export function hasAnyOrgRole(s: SessionWithExtras | null, roles: OrgRole[]): boolean {
if (!s?.org_roles) return false;
return roles.some((r) => s.org_roles?.includes(r));
}
export function hasProduct(s: SessionWithExtras | null, product: string): boolean {
return !!s?.products?.includes(product);
}
// Permission matrix per PLATFORM_ARCHITECTURE.md §5a "Operating principles":
// hide what the user can't access. Each portal surface declares which
// org_roles can see it; the nav uses this to filter links.
export type Surface =
| "dashboard"
| "products"
| "projects"
| "settings"
| "users"
| "api-keys"
| "integrations"
| "billing"
| "audit"
| "support"
| "catalog";
export const surfaceRoles: Record<Surface, OrgRole[]> = {
dashboard: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
products: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
projects: ["IT_ADMIN", "CXO"],
settings: ["IT_ADMIN"],
users: ["IT_ADMIN"],
"api-keys": ["IT_ADMIN"],
integrations: ["IT_ADMIN"],
billing: ["IT_ADMIN", "CXO", "FINANCE"],
audit: ["IT_ADMIN", "CXO", "LEGAL"],
support: ["IT_ADMIN", "CXO", "FINANCE", "LEGAL", "USER"],
catalog: ["IT_ADMIN", "CXO"],
};
export function canSee(s: SessionWithExtras | null, surface: Surface): boolean {
return hasAnyOrgRole(s, surfaceRoles[surface]);
}
+287 -31
View File
@@ -1,14 +1,27 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { fetchTenantBySlug, type Tenant } from "./tenant-registry"; import {
createAPIKey,
createTenant,
fetchAPIKeys,
fetchAudit,
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
requestProduct,
revokeAPIKey,
startTrial,
type Tenant,
} from "./tenant-registry";
const SAMPLE: Tenant = { const SAMPLE: Tenant = {
id: "00000000-0000-0000-0000-000000000001", id: "00000000-0000-0000-0000-000000000001",
slug: "acme", slug: "acme",
name: "Acme Inc.", name: "Acme Inc.",
status: "active", status: "active",
kind: "customer",
plan: "professional", plan: "professional",
products: ["certifai", "compliance"],
created_at: "2026-05-18T22:00:00Z", created_at: "2026-05-18T22:00:00Z",
updated_at: "2026-05-18T22:00:00Z",
}; };
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@@ -20,45 +33,288 @@ afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
beforeEach(() => {
process.env.TENANT_REGISTRY_URL = "http://test:1234";
});
function mockJSON(status: number, body: unknown) {
return vi.fn<typeof fetch>(async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}
describe("fetchTenantBySlug", () => { describe("fetchTenantBySlug", () => {
beforeEach(() => {
process.env.TENANT_REGISTRY_URL = "http://test:1234";
});
test("200 → parsed tenant", async () => { test("200 → parsed tenant", async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 })); globalThis.fetch = mockJSON(200, SAMPLE);
const t = await fetchTenantBySlug("acme"); expect(await fetchTenantBySlug("acme")).toEqual(SAMPLE);
expect(t).toEqual(SAMPLE);
}); });
test("404 → null", async () => { test("404 → null", async () => {
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 })); globalThis.fetch = mockJSON(404, {});
const t = await fetchTenantBySlug("nope"); expect(await fetchTenantBySlug("nope")).toBeNull();
expect(t).toBeNull();
}); });
test("500 → throws", async () => { test("500 → throws", async () => {
globalThis.fetch = vi.fn(async () => new Response("", { status: 500, statusText: "boom" })); globalThis.fetch = mockJSON(500, {});
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/tenant-registry: 500/); await expect(fetchTenantBySlug("acme")).rejects.toThrow(/500/);
}); });
test("default base URL", async () => {
test("falls back to default base URL when env unset", async () => {
delete process.env.TENANT_REGISTRY_URL; delete process.env.TENANT_REGISTRY_URL;
const fetchSpy = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 })); const spy = mockJSON(200, SAMPLE);
globalThis.fetch = fetchSpy; globalThis.fetch = spy;
await fetchTenantBySlug("acme"); await fetchTenantBySlug("acme");
expect(fetchSpy).toHaveBeenCalledWith( expect(spy.mock.calls[0]![0]).toBe("http://localhost:8090/v1/tenants/by-slug/acme");
"http://localhost:8090/v1/tenants/by-slug/acme",
expect.any(Object),
);
}); });
test("encodes slug", async () => {
test("encodes slug to defend against weird input", async () => { const spy = mockJSON(404, {});
const fetchSpy = vi.fn<typeof fetch>(async () => new Response("", { status: 404 })); globalThis.fetch = spy;
globalThis.fetch = fetchSpy;
await fetchTenantBySlug("a/b c"); await fetchTenantBySlug("a/b c");
const firstCall = fetchSpy.mock.calls[0]; expect(spy.mock.calls[0]![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c");
expect(firstCall).toBeDefined(); });
expect(firstCall![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c"); });
describe("fetchCatalog", () => {
test("returns items[]", async () => {
globalThis.fetch = mockJSON(200, {
items: [
{ key: "certifai", name: "CERTifAI", description: "x", plans_required: [], supports_trial: true },
],
});
const list = await fetchCatalog();
expect(list).toHaveLength(1);
expect(list[0].key).toBe("certifai");
});
test("non-200 throws", async () => {
globalThis.fetch = mockJSON(500, {});
await expect(fetchCatalog()).rejects.toThrow();
});
});
describe("fetchEntitlements", () => {
test("happy path", async () => {
globalThis.fetch = mockJSON(200, {
items: [{ tenant_id: "t1", product: "certifai", enabled: true, config: {} }],
});
expect(await fetchEntitlements("t1")).toHaveLength(1);
});
test("404 → []", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await fetchEntitlements("t1")).toEqual([]);
});
});
describe("requestProduct", () => {
test("202 → ok", async () => {
globalThis.fetch = mockJSON(202, { status: "accepted" });
expect(await requestProduct("t1", "certifai")).toEqual({ ok: true });
});
test("404 maps to tenant_not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await requestProduct("t1", "certifai")).toEqual({
ok: false,
error: "tenant_not_found",
});
});
test("400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
});
test("unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(503, {});
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
});
});
describe("startTrial", () => {
test("201 → entitlement", async () => {
globalThis.fetch = mockJSON(201, {
tenant_id: "t1", product: "certifai", enabled: true, config: { source: "trial" },
});
const res = await startTrial("t1", "certifai");
expect(res.ok).toBe(true);
if (res.ok) expect(res.entitlement.product).toBe("certifai");
});
test("400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
});
});
describe("createTenant", () => {
test("201 returns tenant", async () => {
globalThis.fetch = mockJSON(201, {
tenant: SAMPLE,
invite_url: "http://mock/invite",
});
const res = await createTenant({ slug: "x", name: "X", admin_email: "a@b.test" });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.tenant.slug).toBe("acme");
expect(res.invite_url).toBe("http://mock/invite");
}
});
test("409 maps to slug_taken", async () => {
globalThis.fetch = mockJSON(409, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "slug_taken",
});
});
});
describe("coverage gaps", () => {
test("startTrial 404 maps to tenant_not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await startTrial("t1", "x")).toEqual({
ok: false,
error: "tenant_not_found",
});
});
test("startTrial unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(503, {});
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
});
test("createTenant 400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "invalid_input",
});
});
test("createTenant unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(500, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "unexpected_500",
});
});
test("req() handles 204 with null data", async () => {
// Use a verb that returns 204 — none of our endpoints do, but make sure
// the helper handles it. Simulate via fetchEntitlements with 204.
globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
await expect(fetchEntitlements("t1")).rejects.toThrow();
});
test("fetchCatalog with no data throws", async () => {
globalThis.fetch = vi.fn<typeof fetch>(async () =>
new Response("not-json", { status: 200, headers: { "content-type": "text/plain" } }),
);
await expect(fetchCatalog()).rejects.toThrow();
});
});
describe("fetchAPIKeys", () => {
test("happy path", async () => {
globalThis.fetch = mockJSON(200, {
items: [
{ id: "1", tenant_id: "t1", name: "k1", scopes: [], prefix: "bp_a", created_at: "x" },
],
});
const list = await fetchAPIKeys("t1");
expect(list).toHaveLength(1);
});
test("404 → []", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await fetchAPIKeys("t1")).toEqual([]);
});
test("non-200 throws", async () => {
globalThis.fetch = mockJSON(500, {});
await expect(fetchAPIKeys("t1")).rejects.toThrow();
});
});
describe("createAPIKey", () => {
test("201 returns plaintext", async () => {
globalThis.fetch = mockJSON(201, {
api_key: { id: "1", tenant_id: "t1", name: "k", scopes: [], prefix: "bp_a", created_at: "x" },
plaintext: "bp_abc123",
});
const res = await createAPIKey({ tenant_id: "t1", name: "k" });
expect(res.ok).toBe(true);
if (res.ok) expect(res.plaintext).toBe("bp_abc123");
});
test("404 → tenant_not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
ok: false,
error: "tenant_not_found",
});
});
test("400 → invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await createAPIKey({ tenant_id: "t1", name: "" })).toEqual({
ok: false,
error: "invalid_input",
});
});
test("409 → name_taken", async () => {
globalThis.fetch = mockJSON(409, {});
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
ok: false,
error: "name_taken",
});
});
test("unexpected status", async () => {
globalThis.fetch = mockJSON(500, {});
expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
ok: false,
error: "unexpected_500",
});
});
});
describe("revokeAPIKey", () => {
test("204 → ok", async () => {
globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
expect(await revokeAPIKey("k1")).toEqual({ ok: true });
});
test("404 → not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "not_found" });
});
test("unexpected status", async () => {
globalThis.fetch = mockJSON(500, {});
expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "unexpected_500" });
});
});
describe("fetchAudit", () => {
test("happy path with filters", async () => {
const spy = mockJSON(200, {
items: [{ id: 1, action: "tenant.created", created_at: "x" }],
next_cursor: 1,
});
globalThis.fetch = spy;
const res = await fetchAudit({
tenant_id: "t1",
product: "certifai",
actor_id: "u1",
action: "tenant.created",
since: "2026-05-01T00:00:00Z",
until: "2026-05-20T00:00:00Z",
limit: 50,
cursor: 10,
});
expect(res.items).toHaveLength(1);
expect(res.next_cursor).toBe(1);
const url = String(spy.mock.calls[0]![0]);
expect(url).toContain("tenant_id=t1");
expect(url).toContain("product=certifai");
expect(url).toContain("actor_id=u1");
expect(url).toContain("action=tenant.created");
expect(url).toContain("limit=50");
expect(url).toContain("cursor=10");
});
test("no filters", async () => {
const spy = mockJSON(200, { items: [] });
globalThis.fetch = spy;
const res = await fetchAudit({});
expect(res.items).toEqual([]);
const url = String(spy.mock.calls[0]![0]);
expect(url).toBe("http://test:1234/v1/audit?");
});
test("non-200 throws", async () => {
globalThis.fetch = mockJSON(500, {});
await expect(fetchAudit({ tenant_id: "t1" })).rejects.toThrow();
}); });
}); });
+252 -11
View File
@@ -1,29 +1,270 @@
// Tenant Registry client — fetches tenant data from the Go service. // Tenant Registry client — covers everything the portal needs to call
// Skeleton-mode: read-only by-slug lookup. The portal middleware uses this // from server components and server actions.
// to resolve `<slug>.localhost:3000` → tenant context before rendering.
export type Tenant = { export type Tenant = {
id: string; id: string;
slug: string; slug: string;
name: string; name: string;
status: "active" | "trial" | "frozen" | "archived" | "demo"; status: "active" | "trial" | "frozen" | "archived" | "demo";
kind: "customer" | "demo";
plan: "starter" | "professional" | "enterprise"; plan: "starter" | "professional" | "enterprise";
products: string[]; trial_ends_at?: string | null;
created_at: string; created_at: string;
updated_at: string;
};
export type CatalogEntry = {
key: string;
name: string;
description: string;
plans_required: string[];
supports_trial: boolean;
demo_url?: string;
};
export type Entitlement = {
tenant_id: string;
product: string;
enabled: boolean;
config: Record<string, unknown>;
expires_at?: string | null;
};
export type APIKey = {
id: string;
tenant_id: string;
product?: string;
name: string;
scopes: string[];
prefix: string;
created_by?: string;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditEvent = {
id: number;
tenant_id?: string;
actor_id?: string;
actor_name?: string;
actor_type?: string;
action: string;
target_id?: string;
target_type?: string;
target_name?: string;
product?: string;
metadata?: Record<string, unknown>;
source_ip?: string;
user_agent?: string;
created_at: string;
};
export type AuditPage = {
items: AuditEvent[];
next_cursor?: number;
}; };
function baseUrl(): string { function baseUrl(): string {
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090"; return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
} }
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> { async function req<T>(
const res = await fetch(`${baseUrl()}/v1/tenants/by-slug/${encodeURIComponent(slug)}`, { method: string,
path: string,
body?: unknown,
): Promise<{ status: number; data: T | null }> {
const init: RequestInit = {
method,
headers: { accept: "application/json" }, headers: { accept: "application/json" },
cache: "no-store", cache: "no-store",
}); };
if (res.status === 404) return null; if (body !== undefined) {
if (!res.ok) { init.body = JSON.stringify(body);
throw new Error(`tenant-registry: ${res.status} ${res.statusText}`); init.headers = { ...init.headers, "content-type": "application/json" };
} }
return (await res.json()) as Tenant; const res = await fetch(`${baseUrl()}${path}`, init);
if (res.status === 204) return { status: 204, data: null };
const data = (await res.json().catch(() => null)) as T | null;
return { status: res.status, data };
}
// ─── reads ───────────────────────────────────────────────────────────────
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
const { status, data } = await req<Tenant>(
"GET",
`/v1/tenants/by-slug/${encodeURIComponent(slug)}`,
);
if (status === 404) return null;
if (status >= 400 || !data) {
throw new Error(`tenant-registry: GET tenant ${status}`);
}
return data;
}
export async function fetchCatalog(): Promise<CatalogEntry[]> {
const { status, data } = await req<{ items: CatalogEntry[] }>(
"GET",
"/v1/catalog",
);
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET catalog ${status}`);
}
return data.items;
}
export async function fetchEntitlements(tenantId: string): Promise<Entitlement[]> {
const { status, data } = await req<{ items: Entitlement[] }>(
"GET",
`/v1/entitlements?tenant_id=${encodeURIComponent(tenantId)}`,
);
if (status === 404) return [];
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET entitlements ${status}`);
}
return data.items;
}
// ─── mutations ───────────────────────────────────────────────────────────
export type RequestProductResult =
| { ok: true }
| { ok: false; error: string };
export async function requestProduct(
tenantId: string,
product: string,
): Promise<RequestProductResult> {
const { status } = await req<unknown>("POST", "/v1/catalog/request", {
tenant_id: tenantId,
product,
});
if (status === 202) return { ok: true };
if (status === 404) return { ok: false, error: "tenant_not_found" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
export type StartTrialResult =
| { ok: true; entitlement: Entitlement }
| { ok: false; error: string };
export async function startTrial(
tenantId: string,
product: string,
): Promise<StartTrialResult> {
const { status, data } = await req<Entitlement>(
"POST",
"/v1/catalog/trial-request",
{ tenant_id: tenantId, product },
);
if (status === 201 && data) return { ok: true, entitlement: data };
if (status === 404) return { ok: false, error: "tenant_not_found" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
export type CreateTenantInput = {
slug: string;
name: string;
plan?: "starter" | "professional" | "enterprise";
admin_email?: string;
admin_name?: string;
};
export type CreateTenantResult =
| { ok: true; tenant: Tenant; invite_url?: string }
| { ok: false; error: string };
export async function createTenant(
in_: CreateTenantInput,
): Promise<CreateTenantResult> {
const { status, data } = await req<{ tenant: Tenant; invite_url?: string }>(
"POST",
"/v1/tenants",
in_,
);
if (status === 201 && data) {
return { ok: true, tenant: data.tenant, invite_url: data.invite_url };
}
if (status === 409) return { ok: false, error: "slug_taken" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
// ─── api keys ────────────────────────────────────────────────────────────
export async function fetchAPIKeys(tenantId: string): Promise<APIKey[]> {
const { status, data } = await req<{ items: APIKey[] }>(
"GET",
`/v1/api-keys?tenant_id=${encodeURIComponent(tenantId)}`,
);
if (status === 404) return [];
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET api-keys ${status}`);
}
return data.items;
}
export type CreateAPIKeyInput = {
tenant_id: string;
name: string;
product?: string;
scopes?: string[];
created_by?: string;
};
export type CreateAPIKeyResult =
| { ok: true; api_key: APIKey; plaintext: string }
| { ok: false; error: string };
export async function createAPIKey(in_: CreateAPIKeyInput): Promise<CreateAPIKeyResult> {
const { status, data } = await req<{ api_key: APIKey; plaintext: string }>(
"POST",
"/v1/api-keys",
in_,
);
if (status === 201 && data) return { ok: true, api_key: data.api_key, plaintext: data.plaintext };
if (status === 404) return { ok: false, error: "tenant_not_found" };
if (status === 400) return { ok: false, error: "invalid_input" };
if (status === 409) return { ok: false, error: "name_taken" };
return { ok: false, error: `unexpected_${status}` };
}
export async function revokeAPIKey(id: string): Promise<{ ok: boolean; error?: string }> {
const { status } = await req<unknown>("DELETE", `/v1/api-keys/${encodeURIComponent(id)}`);
if (status === 204) return { ok: true };
if (status === 404) return { ok: false, error: "not_found" };
return { ok: false, error: `unexpected_${status}` };
}
// ─── audit ───────────────────────────────────────────────────────────────
export type AuditFilter = {
tenant_id?: string;
product?: string;
actor_id?: string;
action?: string;
since?: string;
until?: string;
limit?: number;
cursor?: number;
};
export async function fetchAudit(f: AuditFilter): Promise<AuditPage> {
const qs = new URLSearchParams();
if (f.tenant_id) qs.set("tenant_id", f.tenant_id);
if (f.product) qs.set("product", f.product);
if (f.actor_id) qs.set("actor_id", f.actor_id);
if (f.action) qs.set("action", f.action);
if (f.since) qs.set("since", f.since);
if (f.until) qs.set("until", f.until);
if (f.limit) qs.set("limit", String(f.limit));
if (f.cursor) qs.set("cursor", String(f.cursor));
const { status, data } = await req<AuditPage>("GET", `/v1/audit?${qs.toString()}`);
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET audit ${status}`);
}
return data;
} }
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
// Apex tests don't need the OIDC dance — they just verify Next.js is
// serving the right routes.
test.describe("apex landing", () => {
test("renders the landing page @needs-stack", async ({ page }) => {
const apex = test.info().config.metadata?.apexURL ?? "http://localhost:3000";
await page.goto(apex);
await expect(page.getByRole("heading", { name: "Breakpilot" })).toBeVisible();
await expect(page.getByText(/Customer portals live at/)).toBeVisible();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { expect, test } from "@playwright/test";
// Health checks — confirm the underlying services are reachable before the
// stack-dependent tests run. Skips with a clear reason if anything is down,
// so we don't waste 30s on a slow OIDC redirect when Keycloak isn't running.
test.describe("dev stack health @needs-stack", () => {
test("portal /api/auth/providers responds", async ({ request }) => {
const r = await request.get("/api/auth/providers");
expect(r.status()).toBe(200);
const data = await r.json();
expect(data).toHaveProperty("keycloak");
});
test("tenant-registry /healthz responds", async ({ request }) => {
const url =
process.env.PLAYWRIGHT_TENANT_REGISTRY_URL ?? "http://localhost:8090";
const r = await request.get(`${url}/healthz`);
expect(r.status()).toBe(200);
expect(await r.json()).toMatchObject({ status: "ok" });
});
test("keycloak realm metadata is exposed", async ({ request }) => {
const url =
process.env.PLAYWRIGHT_KEYCLOAK_URL ??
"http://localhost:8080/realms/breakpilot-dev";
const r = await request.get(`${url}/.well-known/openid-configuration`);
expect(r.status()).toBe(200);
const cfg = await r.json();
expect(cfg.issuer).toContain("breakpilot-dev");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from "@playwright/test";
// One canary per shell surface — confirms the route mounts and renders
// SOMETHING the user can see (heading or 403 gate) without OIDC.
// All run signed-out, so role-gated routes land on the NotAuthorized 403.
test.describe("customer-area surfaces @needs-stack", () => {
const surfaces = [
{ path: "/products", expected: "403" },
{ path: "/projects", expected: "403" },
{ path: "/catalog", expected: "403" },
{ path: "/settings", expected: "403" },
{ path: "/settings/users", expected: "403" },
{ path: "/settings/api-keys", expected: "403" },
{ path: "/settings/integrations", expected: "403" },
{ path: "/billing", expected: "403" },
{ path: "/audit", expected: "403" },
{ path: "/support", expected: "403" },
];
for (const { path, expected } of surfaces) {
test(`acme${path} renders signed-out`, async ({ page }) => {
await page.goto(path);
await expect(page.getByRole("heading", { name: new RegExp(expected, "i") })).toBeVisible();
});
}
});
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
// Tenant subdomain tests — no OIDC click-through (Keycloak in headless mode
// is flaky); we just assert the SIGNED-OUT view of each protected route
// renders the expected gate. Once the realm grows a service-account flow
// for testing (M5.x), we'll bolt on a signed-in suite.
test.describe("tenant subdomain @needs-stack", () => {
test("acme dashboard renders the Sign-in button when signed out", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: /Sign in to acme/i })).toBeVisible();
await expect(page.getByRole("button", { name: /Sign in with Keycloak/i })).toBeVisible();
});
test("acme /products is gated when signed out", async ({ page }) => {
await page.goto("/products");
// Signed-out session means canSee(null, "products") is false, so the
// page renders the 'NotAuthorized' component. The dashboard route is
// the only place that surfaces the Sign-in button today — we'll add
// a redirect-to-sign-in in M10.1 once the surface set firms up.
await expect(page.getByRole("heading", { name: /403/i })).toBeVisible();
});
test("unknown tenant slug 404s", async ({ page }) => {
const resp = await page.goto("http://nope-nope.localhost:3000/dashboard");
expect(resp?.status()).toBe(404);
});
});
+4 -2
View File
@@ -38,6 +38,8 @@
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",
".next" ".next",
"tests/e2e",
"tests"
] ]
} }