From 91a655b6df2b1ee84615d3dd8091148ce6e6d65b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:35:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M10.2=20=E2=80=94=20restyle=20P?= =?UTF-8?q?roducts=20+=20Org=20+=20Team=20+=20Billing=20+=20Audit=20+=20SS?= =?UTF-8?q?O?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/[slug]/audit/page.tsx | 266 ++++++------------ src/app/[slug]/billing/page.tsx | 171 +++++++++-- src/app/[slug]/products/page.tsx | 245 +++++++++------- src/app/[slug]/settings/integrations/page.tsx | 83 +++++- src/app/[slug]/settings/page.tsx | 204 +++++++------- src/app/[slug]/settings/users/page.tsx | 86 +++++- src/components/portal/NotAllowed.tsx | 25 ++ 7 files changed, 644 insertions(+), 436 deletions(-) create mode 100644 src/components/portal/NotAllowed.tsx diff --git a/src/app/[slug]/audit/page.tsx b/src/app/[slug]/audit/page.tsx index 12d9d49..83a5a53 100644 --- a/src/app/[slug]/audit/page.tsx +++ b/src/app/[slug]/audit/page.tsx @@ -1,207 +1,101 @@ -import Link from "next/link"; -import { redirect } from "next/navigation"; -import { auth } from "@/auth"; -import { NotAuthorized } from "@/components/ShellEmpty"; -import { formatDateTime, formatRelative, truncate } from "@/lib/format"; -import type { SessionWithExtras } from "@/lib/session"; import { canSee } from "@/lib/session"; -import { fetchAudit, fetchTenantBySlug } from "@/lib/tenant-registry"; +import { getPortalSession } from "@/lib/get-session"; +import { loadTenantForShell } from "@/lib/portal-data"; +import { Panel } from "@/components/portal/Panel"; +import { NotAllowed } from "@/components/portal/NotAllowed"; -const PAGE_SIZE = 50; +const EVENT_FILTERS = ["all", "auth", "scan", "finding", "evidence", "billing", "settings"]; export default async function AuditPage({ params, searchParams, }: { params: Promise<{ slug: string }>; - searchParams: Promise<{ cursor?: string; action?: string; actor_id?: string }>; + searchParams: Promise<{ q?: string; type?: string; product?: string }>; }) { const { slug } = await params; - const q = await searchParams; - const session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "audit")) return ; + const sp = await searchParams; + const session = await getPortalSession(); + if (!canSee(session, "audit")) return ; + const t = await loadTenantForShell(slug); + if (!t) return null; - const tenant = await fetchTenantBySlug(slug); - if (!tenant) redirect(`/${slug}/dashboard`); + const q = sp.q?.toLowerCase() ?? ""; + const type = sp.type ?? "all"; + const product = sp.product ?? "all"; - const cursor = q.cursor ? Number(q.cursor) : undefined; - const page = await fetchAudit({ - tenant_id: tenant.id, - action: q.action || undefined, - actor_id: q.actor_id || undefined, - limit: PAGE_SIZE, - cursor: cursor && !Number.isNaN(cursor) ? cursor : undefined, + 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; }); - const nextHref = page.next_cursor - ? buildHref(slug, { ...q, cursor: String(page.next_cursor) }) - : null; - const resetHref = (q.action || q.actor_id || q.cursor) ? `/${slug}/audit` : null; - return ( -
-

Audit log

-

- Every state-changing action emitted by the portal and the products.{" "} - - Retraced-shape schema - {" "} - — CSV / PDF export lands in M10.2. -

- - - - {page.items.length === 0 ? ( -

- No events match the current filter. -

- ) : ( -
- - - - - - - - - - - - - {page.items.map((ev) => ( - - - - - - - - - ))} - -
WhenActionActorTargetProductMeta
- {formatRelative(ev.created_at)} - - {ev.action} - - {ev.actor_name || ev.actor_id || ( - system - )} - - {ev.target_type && ( - {ev.target_type}: - )}{" "} - {ev.target_name || ev.target_id || ( - - )} - - {ev.product || portal} - - {ev.metadata && Object.keys(ev.metadata).length > 0 - ? truncate(JSON.stringify(ev.metadata), 50) - : ""} -
-
- )} - -
+
+
- {resetHref && ( - - ← Clear filters - - )} +
Audit log
+
+ {rows.length} of {t.audit.length}{" "} + events · retention 365 days · hash-chained +
-
- {nextHref && ( - - Next page → - - )} +
+
-
+ + +
+ + + {EVENT_FILTERS.map((f) => ( + + ))} + + + + + + + + + + + + + + + + + + {rows.slice(0, 50).map((r, i) => ( + + + + + + + + + ))} + +
WhenEventActorProductSource IPResult
{r.date} {r.time}{r.event}{r.actor}{r.product}{r.ip} + + + {r.result === "denied" ? "DENIED" : "OK"} + +
+
+ ); } - -function Filters({ - slug, - active, -}: { - slug: string; - active: { action?: string; actor_id?: string }; -}) { - return ( -
- - - -
- ); -} - -function buildHref(slug: string, q: Record): string { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(q)) { - if (v) qs.set(k, v); - } - const s = qs.toString(); - return s ? `/${slug}/audit?${s}` : `/${slug}/audit`; -} - -const inputStyle: React.CSSProperties = { - padding: "8px 10px", - border: "1px solid #ddd", - borderRadius: 6, - fontSize: 14, -}; -const btnLink: React.CSSProperties = { - color: "#0070f3", - fontSize: 13, - textDecoration: "none", -}; -const btnSmall: React.CSSProperties = { - padding: "4px 10px", - background: "white", - color: "#0070f3", - border: "1px solid #0070f3", - borderRadius: 4, - fontSize: 12, - cursor: "pointer", -}; -const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 }; -const td: React.CSSProperties = { padding: "8px 10px" }; diff --git a/src/app/[slug]/billing/page.tsx b/src/app/[slug]/billing/page.tsx index 21b1f32..887823a 100644 --- a/src/app/[slug]/billing/page.tsx +++ b/src/app/[slug]/billing/page.tsx @@ -1,31 +1,160 @@ -import Link from "next/link"; -import { auth } from "@/auth"; -import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; -import type { SessionWithExtras } from "@/lib/session"; import { canSee } from "@/lib/session"; +import { getPortalSession } from "@/lib/get-session"; +import { loadTenantForShell } from "@/lib/portal-data"; +import { Panel } from "@/components/portal/Panel"; +import { NotAllowed } from "@/components/portal/NotAllowed"; +import { CreditCard, AlertTriangle } from "lucide-react"; -export default async function Page({ +const EUR = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }); + +export default async function BillingPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; - const session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "billing")) return ; + const session = await getPortalSession(); + if (!canSee(session, "billing")) return ; + 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 ( - - Browse the catalog → - - } - /> +
+
+
+
Billing
+
Plan, usage, payment method and invoice history
+
+
+ +
+
+ +
+
+ +
+
+
PLAN
+
{t.plan}
+
{t.planCode}
+
+
+
MONTHLY
+
+ {EUR.format(t.monthly)} +
+
+ + 19% VAT = {EUR.format(monthlyGross)} +
+
+
+
+
+ SEATS + {t.seats.used} / {t.seats.total} +
+
+
+ EVIDENCE STORAGE + + {evidenceStorageUsed} / {evidenceStorageMax} GB + +
+
90 ? "warn" : ""}`}> + +
+ + + + {frozen ? ( +
+ + Payment failed + + + +
+ ) : ( +
+ + + +
+
SEPA Direct Debit
+
+ DE89 •••• •••• 7421 · {t.contact} +
+
+ + +
+ )} +
+
+ +
+ +
+
Renews on
+
+ {t.renewal} +
+
Billing email
{t.contactEmail}
+
Currency
EUR
+
VAT applied
19% (DE)
+
+
+
+
+ + {t.invoices.length} entries} pad={false}> + + + + + + + + + + + + + + + + {t.invoices.map((inv) => ( + + + + + + + + + + + + ))} + +
InvoicePeriodIssuedSeatsNetVATTotalStatus
{inv.id}{inv.period}{inv.issued}{inv.seats}{EUR.format(inv.net)}{EUR.format(inv.vat)}{EUR.format(inv.total)} + + + {inv.status === "due" ? "Due" : "Paid"} + + + +
+
+
); } diff --git a/src/app/[slug]/products/page.tsx b/src/app/[slug]/products/page.tsx index 26d069d..634119d 100644 --- a/src/app/[slug]/products/page.tsx +++ b/src/app/[slug]/products/page.tsx @@ -1,118 +1,159 @@ -import { redirect } from "next/navigation"; -import { auth } from "@/auth"; -import { NotAuthorized } from "@/components/ShellEmpty"; -import { formatDateTime, formatRelative } from "@/lib/format"; -import type { SessionWithExtras } from "@/lib/session"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; import { canSee } from "@/lib/session"; -import { - fetchCatalog, - fetchEntitlements, - fetchTenantBySlug, - type CatalogEntry, - type Entitlement, -} from "@/lib/tenant-registry"; +import { getPortalSession } from "@/lib/get-session"; +import { loadTenantForShell } from "@/lib/portal-data"; +import { Panel } from "@/components/portal/Panel"; +import { Monogram } from "@/components/portal/Monogram"; +import { Sev } from "@/components/portal/Sev"; +import { 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 session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "products")) return ; + const sp = await searchParams; + const session = await getPortalSession(); + if (!canSee(session, "products")) return ; + const t = await loadTenantForShell(slug); + if (!t) return null; - const tenant = await fetchTenantBySlug(slug); - if (!tenant) redirect(`/${slug}/dashboard`); - - const [catalog, entitlements] = await Promise.all([ - fetchCatalog(), - fetchEntitlements(tenant.id), - ]); - - const byKey = new Map(catalog.map((c) => [c.key, c])); - const active = entitlements.filter((e) => e.enabled); + const productFilter = sp.p ?? "all"; + const findings = t.findings.filter((f) => productFilter === "all" || f.product === productFilter); return ( -
-

Products

-

- Live entitlements for {tenant.name}. Open a product to - use its web component (M6.x / M7.x). -

- - {active.length === 0 ? ( -
-

No products yet.

- - Browse the catalog → - +
+
+
+
Products
+
+ {t.entitled.length} entitled · click a product to open its launch screen +
- ) : ( -
    - {active.map((e) => ( - - ))} -
- )} -
- ); -} +
-function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) { - return ( -
  • -
    - {catalog?.name ?? ent.product} - {ent.expires_at && ( - - trial · {formatRelative(ent.expires_at)} +
    + {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 ( +
    +
    + +
    + {p.name} + {p.slug} +
    +
    +

    {p.blurb}

    +
    + Coming soon +
    +
    + ); + } + + return ( + +
    + +
    + {p.name} + {p.slug} +
    + +
    +

    {p.blurb}

    +
    + {p.frameworks.map((f) => ( + {f} + ))} +
    +
    +
    +
    {openCount}
    +
    Open
    +
    +
    +
    {evidence}
    +
    Evidence
    +
    +
    +
    + {entitled ? ( + Entitled + ) : trialing ? ( + Trialing + ) : null} +
    +
    Plan
    +
    +
    + + ); + })} +
    + + + {["all", "compliance-scanner", "certifai"].map((o) => ( + + {o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"} + + ))}
    - )} -
    - {catalog?.description && ( -

    - {catalog.description} -

    - )} -
    - Web component renders here once {ent.product}-dashboard is - registered (M6.3 / M7.2). -
    -
  • + } + pad={false} + > + + + + + + + + + + + + + + {findings.slice(0, 12).map((f) => ( + + + + + + + + + + ))} + +
    SevIDTitleProductControlAgeStatus
    {f.id} + {f.title} + {f.product}{f.control}{f.ageDays}d + + + {f.status === "resolved" ? "Resolved" : "Open"} + +
    + + ); } diff --git a/src/app/[slug]/settings/integrations/page.tsx b/src/app/[slug]/settings/integrations/page.tsx index b13ead3..2528001 100644 --- a/src/app/[slug]/settings/integrations/page.tsx +++ b/src/app/[slug]/settings/integrations/page.tsx @@ -1,17 +1,76 @@ -import { auth } from "@/auth"; -import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; -import type { SessionWithExtras } from "@/lib/session"; import { canSee } from "@/lib/session"; +import { 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 ; + const t = await loadTenantForShell(slug); + if (!t) return null; + + const groupMappings: [string, string][] = [ + [`${t.id}/groups/it-admins`, "IT_ADMIN"], + [`${t.id}/groups/cxo`, "CXO"], + [`${t.id}/groups/finance`, "FINANCE"], + [`${t.id}/groups/legal`, "LEGAL"], + [`${t.id}/groups/all-users`, "USER"], + ]; -export default async function Page() { - const session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "integrations")) return ; return ( - +
    +
    +
    +
    SSO
    +
    Single sign-on via Keycloak (OIDC) — managed by Breakpilot Platform
    +
    +
    + + + connection healthy + +
    +
    + +
    +
    + +
    +
    Provider
    Keycloak (breakpilot-dev realm)
    +
    Protocol
    OpenID Connect / authorization_code + PKCE
    +
    Issuer
    {process.env.KEYCLOAK_ISSUER ?? "http://localhost:8080/realms/breakpilot-dev"}
    +
    Client ID
    {process.env.KEYCLOAK_CLIENT_ID ?? "dev-portal"}
    +
    Redirect URI
    {`https://${t.id}.breakpilot.eu/api/auth/callback/keycloak`}
    +
    Scopes
    openid profile email tenant-context
    +
    Signing alg
    RS256 (JWKS, rotated 90d)
    +
    +
    +
    + +
    + + + + + + + {groupMappings.map(([g, role]) => ( + + + + + ))} + +
    Keycloak groupMaps to
    {g}{role}
    +
    +
    +
    +
    ); } diff --git a/src/app/[slug]/settings/page.tsx b/src/app/[slug]/settings/page.tsx index 9d76c9f..f599116 100644 --- a/src/app/[slug]/settings/page.tsx +++ b/src/app/[slug]/settings/page.tsx @@ -1,120 +1,118 @@ -import { auth } from "@/auth"; -import { NotAuthorized } from "@/components/ShellEmpty"; -import { formatDateTime } from "@/lib/format"; -import type { SessionWithExtras } from "@/lib/session"; import { canSee } from "@/lib/session"; -import { fetchTenantBySlug } from "@/lib/tenant-registry"; +import { getPortalSession } from "@/lib/get-session"; +import { loadTenantForShell } from "@/lib/portal-data"; +import { Panel } from "@/components/portal/Panel"; +import { Monogram } from "@/components/portal/Monogram"; +import { NotAllowed } from "@/components/portal/NotAllowed"; -export default async function SettingsPage({ +// "Organization" — IT_ADMIN only. +export default async function OrganizationPage({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; - const session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "settings")) return ; + const session = await getPortalSession(); + if (!canSee(session, "settings")) return ; + const t = await loadTenantForShell(slug); + if (!t) return null; - const tenant = await fetchTenantBySlug(slug); - if (!tenant) { - return ( -
    -

    Settings

    -

    Tenant not found.

    -
    - ); - } + 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 ( -
    -

    Settings

    -

    - Tenant identity and lifecycle metadata. Editing these lands in the - M10.1 follow-up; for now contact support. -

    +
    +
    +
    +
    Organization
    +
    + Tenant profile, entitlements & primary contact +
    +
    +
    + + +
    +
    -

    Identity

    - - - - +
    +
    + +
    +
    Legal name
    {t.name}
    +
    Form
    {t.legalType}
    +
    Registered
    {t.city} · {t.country}
    +
    VAT ID
    {t.vat}
    +
    Tenant ID
    {t.id}
    +
    Customer since
    {t.since}
    +
    +
    -

    Plan & status

    - - - {tenant.trial_ends_at && ( - - )} + +
    + + {t.contact.split(" ").map((s) => s[0]).join("")} + +
    +
    {t.contact}
    +
    {t.contactEmail}
    +
    + + ADMIN OWNER +
    +
    +
    -

    Audit

    - - +
    + +
    +
    +
    PLAN
    +
    {t.plan}
    +
    {t.planCode}
    +
    +
    +
    RENEWS
    +
    {t.renewal}
    +
    +
    +
    + SEATS + {t.seats.used} / {t.seats.total} +
    +
    +
    {seatsLeft} seats available
    +
    -

    External links

    -

    - ERPNext customer + Polar subscription land in M8.3; rendered here when - the IDs land on the tenant row. -

    -
    - ); -} - -function Field({ - label, - value, - mono, - badge, -}: { - label: string; - value: string; - mono?: boolean; - badge?: string; -}) { - return ( -
    - {label} - - {badge ? ( - - {value} - - ) : ( - value - )} - + {subscribed.length} active} pad={false}> +
    + {subscribed.map((p) => ( +
    + + + {p.name} + + ENTITLED +
    + ))} + {trialing.map((p) => ( +
    + + + {p.name} + + TRIALING +
    + ))} + {subscribed.length === 0 && trialing.length === 0 ? ( +
    No active products.
    + ) : null} +
    +
    +
    + ); } - -function statusColor(s: string): string { - switch (s) { - case "active": - return "#1a7a3e"; - case "trial": - return "#a87a00"; - case "frozen": - return "#a82626"; - case "archived": - return "#666"; - case "demo": - return "#0070f3"; - default: - return "#444"; - } -} diff --git a/src/app/[slug]/settings/users/page.tsx b/src/app/[slug]/settings/users/page.tsx index 62a3ab2..8b58fc6 100644 --- a/src/app/[slug]/settings/users/page.tsx +++ b/src/app/[slug]/settings/users/page.tsx @@ -1,17 +1,79 @@ -import { auth } from "@/auth"; -import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty"; -import type { SessionWithExtras } from "@/lib/session"; import { canSee } from "@/lib/session"; +import { 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 ; + const t = await loadTenantForShell(slug); + if (!t) return null; + const team = t.team; -export default async function Page() { - const session = (await auth()) as SessionWithExtras | null; - if (!canSee(session, "users")) return ; return ( - +
    +
    +
    +
    Team
    +
    + {team.length} members ·{" "} + {t.seats.used}/{t.seats.total} seats used +
    +
    +
    + + +
    +
    + + + + + + + + + + + + + + {team.map((m, i) => ( + + + + + + + + ))} + +
    MemberEmailRolesLast activeStatus
    + + + {m.name.split(" ").map((s) => s[0]).join("")} + + {m.name} + + {m.email} + + {m.roles.map((r) => ( + {r} + ))} + + {m.last} + + + {m.status === "invited" ? "Invited" : "Active"} + +
    +
    +
    ); } diff --git a/src/components/portal/NotAllowed.tsx b/src/components/portal/NotAllowed.tsx new file mode 100644 index 0000000..ad20876 --- /dev/null +++ b/src/components/portal/NotAllowed.tsx @@ -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 ( +
    +
    +
    +
    403 · NOT AUTHORIZED
    +

    + 403 +

    +

    + Your roles don't include access to this screen. +

    +

    + Requires {need} +

    +
    +
    +
    + ); +}