M10.2 design system — tokens, shell + 7 customer-area screens restyled #13
+80
-186
@@ -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" };
|
||||
|
||||
+150
-21
@@ -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 <NotAuthorized />;
|
||||
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 (
|
||||
<ShellEmpty
|
||||
title="Billing"
|
||||
description="Plan, seats, invoices. Polar Checkout opens here for plan changes."
|
||||
milestone="M8.3"
|
||||
details="Polar.sh is the Merchant of Record — it handles EU VAT MOSS so we don't have to. Invoices mirror to ERPNext (Customer + Sales Invoice) so accounting stays in one place."
|
||||
cta={
|
||||
<Link
|
||||
href={`/${slug}/catalog`}
|
||||
style={{ color: "#0070f3", fontSize: 14, textDecoration: "underline" }}
|
||||
>
|
||||
Browse the catalog →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+143
-102
@@ -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 <NotAuthorized />;
|
||||
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 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 (
|
||||
<section>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Products</h1>
|
||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
||||
Live entitlements for <strong>{tenant.name}</strong>. Open a product to
|
||||
use its web component (M6.x / M7.x).
|
||||
</p>
|
||||
|
||||
{active.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
border: "1px dashed #ddd",
|
||||
borderRadius: 8,
|
||||
background: "#fafafa",
|
||||
color: "#666",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<p style={{ marginBottom: 8 }}>No products yet.</p>
|
||||
<a href={`/${slug}/catalog`} style={{ color: "#0070f3" }}>
|
||||
Browse the catalog →
|
||||
</a>
|
||||
<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>
|
||||
) : (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
}}
|
||||
>
|
||||
{active.map((e) => (
|
||||
<ProductCard key={e.product} ent={e} catalog={byKey.get(e.product)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
|
||||
function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) {
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
padding: 16,
|
||||
border: "1px solid #eaeaea",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<strong style={{ fontSize: 15 }}>{catalog?.name ?? ent.product}</strong>
|
||||
{ent.expires_at && (
|
||||
<span
|
||||
title={formatDateTime(ent.expires_at)}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
background: "#fff7e0",
|
||||
color: "#7a5a00",
|
||||
}}
|
||||
>
|
||||
trial · {formatRelative(ent.expires_at)}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{catalog?.description && (
|
||||
<p style={{ color: "#666", fontSize: 13, marginTop: 6, marginBottom: 12 }}>
|
||||
{catalog.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: "#999", marginTop: 8 }}>
|
||||
Web component renders here once <code>{ent.product}-dashboard</code> is
|
||||
registered (M6.3 / M7.2).
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <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"],
|
||||
];
|
||||
|
||||
export default async function Page() {
|
||||
const session = (await auth()) as SessionWithExtras | null;
|
||||
if (!canSee(session, "integrations")) return <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Integrations"
|
||||
description="Webhooks, outbound integrations, and external IdP configuration."
|
||||
milestone="M15.2"
|
||||
details="Webhook delivery (signed payloads, retry-with-backoff, dead-letter queue) lands alongside the headless-product API surface."
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+101
-103
@@ -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 <NotAuthorized />;
|
||||
const session = await getPortalSession();
|
||||
if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
|
||||
const t = await loadTenantForShell(slug);
|
||||
if (!t) return null;
|
||||
|
||||
const tenant = await fetchTenantBySlug(slug);
|
||||
if (!tenant) {
|
||||
return (
|
||||
<section>
|
||||
<h1 style={{ fontSize: 28 }}>Settings</h1>
|
||||
<p style={{ color: "#a82626" }}>Tenant not found.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<section>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Settings</h1>
|
||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
||||
Tenant identity and lifecycle metadata. Editing these lands in the
|
||||
M10.1 follow-up; for now contact <a href={`/${slug}/support`}>support</a>.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<h2 style={{ fontSize: 18, marginTop: 16, marginBottom: 8 }}>Identity</h2>
|
||||
<Field label="Tenant ID" value={tenant.id} mono />
|
||||
<Field label="Slug" value={tenant.slug} mono />
|
||||
<Field label="Name" value={tenant.name} />
|
||||
<Field label="Kind" value={tenant.kind} />
|
||||
<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>
|
||||
|
||||
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>Plan & status</h2>
|
||||
<Field label="Plan" value={tenant.plan} />
|
||||
<Field label="Status" value={tenant.status} badge={statusColor(tenant.status)} />
|
||||
{tenant.trial_ends_at && (
|
||||
<Field label="Trial ends at" value={formatDateTime(tenant.trial_ends_at)} mono />
|
||||
)}
|
||||
<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>
|
||||
|
||||
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>Audit</h2>
|
||||
<Field label="Created" value={formatDateTime(tenant.created_at)} mono />
|
||||
<Field label="Last updated" value={formatDateTime(tenant.updated_at)} mono />
|
||||
<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>
|
||||
|
||||
<h2 style={{ fontSize: 18, marginTop: 24, marginBottom: 8 }}>External links</h2>
|
||||
<p style={{ fontSize: 13, color: "#666" }}>
|
||||
ERPNext customer + Polar subscription land in M8.3; rendered here when
|
||||
the IDs land on the tenant row.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", padding: "6px 0", borderBottom: "1px solid #f0f0f0" }}>
|
||||
<span style={{ width: 160, color: "#666", fontSize: 13 }}>{label}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: mono
|
||||
? "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
|
||||
: "inherit",
|
||||
fontSize: mono ? 13 : 14,
|
||||
}}
|
||||
>
|
||||
{badge ? (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
background: badge,
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <NotAllowed need="IT_ADMIN" />;
|
||||
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 <NotAuthorized />;
|
||||
return (
|
||||
<ShellEmpty
|
||||
title="Users"
|
||||
description="Invite teammates as IT_ADMIN, CXO, FINANCE, LEGAL, or USER."
|
||||
milestone="M10.1 follow-up"
|
||||
details="User management calls Keycloak's Organizations Admin API. The adapter exists (internal/keycloak in tenant-registry); this UI just needs the list + invite handlers wired."
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user