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>
This commit is contained in:
Sharang Parnerkar
2026-06-04 13:35:25 +02:00
co-authored by Claude Opus 4.7
parent f3c95123fa
commit 91a655b6df
7 changed files with 644 additions and 436 deletions
+80 -186
View File
@@ -1,207 +1,101 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { NotAuthorized } from "@/components/ShellEmpty";
import { formatDateTime, formatRelative, truncate } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
import { 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 <NotAuthorized />;
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 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 (
<section>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Audit log</h1>
<p style={{ color: "#444", marginBottom: 16 }}>
Every state-changing action emitted by the portal and the products.{" "}
<a
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PRODUCT_INTEGRATION_SPEC.md"
style={{ color: "#0070f3" }}
>
Retraced-shape schema
</a>{" "}
CSV / PDF export lands in M10.2.
</p>
<Filters slug={slug} active={{ action: q.action, actor_id: q.actor_id }} />
{page.items.length === 0 ? (
<p style={{ color: "#666", fontSize: 14, marginTop: 16 }}>
No events match the current filter.
</p>
) : (
<div style={{ overflow: "auto", marginTop: 16 }}>
<table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "1px solid #eaeaea" }}>
<th style={th}>When</th>
<th style={th}>Action</th>
<th style={th}>Actor</th>
<th style={th}>Target</th>
<th style={th}>Product</th>
<th style={th}>Meta</th>
</tr>
</thead>
<tbody>
{page.items.map((ev) => (
<tr key={ev.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={td} title={formatDateTime(ev.created_at)}>
{formatRelative(ev.created_at)}
</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace" }}>
{ev.action}
</td>
<td style={td}>
{ev.actor_name || ev.actor_id || (
<em style={{ color: "#999" }}>system</em>
)}
</td>
<td style={td}>
{ev.target_type && (
<span style={{ color: "#666" }}>{ev.target_type}:</span>
)}{" "}
{ev.target_name || ev.target_id || (
<em style={{ color: "#999" }}></em>
)}
</td>
<td style={td}>
{ev.product || <em style={{ color: "#999" }}>portal</em>}
</td>
<td style={{ ...td, fontFamily: "ui-monospace, monospace", color: "#666" }}>
{ev.metadata && Object.keys(ev.metadata).length > 0
? truncate(JSON.stringify(ev.metadata), 50)
: ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: 16, display: "flex", justifyContent: "space-between" }}>
<div className="content-inner">
<div className="page-head">
<div>
{resetHref && (
<Link href={resetHref as `/${string}`} style={btnLink}>
Clear filters
</Link>
)}
<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>
{nextHref && (
<Link href={nextHref as `/${string}`} style={btnLink}>
Next page
</Link>
)}
<div className="ph-actions">
<button type="button" className="btn">Export (CSV)</button>
</div>
</div>
</section>
<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>
);
}
function Filters({
slug,
active,
}: {
slug: string;
active: { action?: string; actor_id?: string };
}) {
return (
<form
action={`/${slug}/audit`}
method="GET"
style={{
display: "flex",
gap: 8,
marginTop: 8,
padding: 12,
background: "#fafafa",
border: "1px solid #eaeaea",
borderRadius: 6,
}}
>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
action
<input
name="action"
defaultValue={active.action ?? ""}
placeholder="tenant.created"
style={{ ...inputStyle, padding: "4px 8px", fontSize: 13 }}
/>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
actor_id
<input
name="actor_id"
defaultValue={active.actor_id ?? ""}
placeholder="kc user id"
style={{ ...inputStyle, padding: "4px 8px", fontSize: 13 }}
/>
</label>
<button type="submit" style={btnSmall}>
Filter
</button>
</form>
);
}
function buildHref(slug: string, q: Record<string, string | undefined>): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(q)) {
if (v) qs.set(k, v);
}
const s = qs.toString();
return s ? `/${slug}/audit?${s}` : `/${slug}/audit`;
}
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 14,
};
const btnLink: React.CSSProperties = {
color: "#0070f3",
fontSize: 13,
textDecoration: "none",
};
const btnSmall: React.CSSProperties = {
padding: "4px 10px",
background: "white",
color: "#0070f3",
border: "1px solid #0070f3",
borderRadius: 4,
fontSize: 12,
cursor: "pointer",
};
const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 };
const td: React.CSSProperties = { padding: "8px 10px" };