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} +

    +
    +
    +
    + ); +}