feat(portal): M10.2 — restyle Products + Org + Team + Billing + Audit + SSO
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:
+72
-178
@@ -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 { 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({
|
export default async function AuditPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
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 { slug } = await params;
|
||||||
const q = await searchParams;
|
const sp = await searchParams;
|
||||||
const session = (await auth()) as SessionWithExtras | null;
|
const session = await getPortalSession();
|
||||||
if (!canSee(session, "audit")) return <NotAuthorized />;
|
if (!canSee(session, "audit")) return <NotAllowed need="LEGAL / IT_ADMIN" />;
|
||||||
|
const t = await loadTenantForShell(slug);
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
const tenant = await fetchTenantBySlug(slug);
|
const q = sp.q?.toLowerCase() ?? "";
|
||||||
if (!tenant) redirect(`/${slug}/dashboard`);
|
const type = sp.type ?? "all";
|
||||||
|
const product = sp.product ?? "all";
|
||||||
|
|
||||||
const cursor = q.cursor ? Number(q.cursor) : undefined;
|
const rows = t.audit.filter((r) => {
|
||||||
const page = await fetchAudit({
|
if (q && !`${r.event} ${r.actor} ${r.product}`.toLowerCase().includes(q)) return false;
|
||||||
tenant_id: tenant.id,
|
if (type !== "all" && !r.event.startsWith(type)) return false;
|
||||||
action: q.action || undefined,
|
if (product !== "all" && r.product !== product) return false;
|
||||||
actor_id: q.actor_id || undefined,
|
return true;
|
||||||
limit: PAGE_SIZE,
|
|
||||||
cursor: cursor && !Number.isNaN(cursor) ? cursor : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<section>
|
<div className="content-inner">
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Audit log</h1>
|
<div className="page-head">
|
||||||
<p style={{ color: "#444", marginBottom: 16 }}>
|
<div>
|
||||||
Every state-changing action emitted by the portal and the products.{" "}
|
<div className="page-title">Audit log</div>
|
||||||
<a
|
<div className="page-sub">
|
||||||
href="https://gitea.meghsakha.com/platform/docs/src/branch/main/PRODUCT_INTEGRATION_SPEC.md"
|
<span className="mono">{rows.length}</span> of <span className="mono">{t.audit.length}</span>{" "}
|
||||||
style={{ color: "#0070f3" }}
|
events · retention 365 days · hash-chained
|
||||||
>
|
</div>
|
||||||
Retraced-shape schema
|
</div>
|
||||||
</a>{" "}
|
<div className="ph-actions">
|
||||||
— CSV / PDF export lands in M10.2.
|
<button type="button" className="btn">Export (CSV)</button>
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Filters slug={slug} active={{ action: q.action, actor_id: q.actor_id }} />
|
<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>
|
||||||
|
|
||||||
{page.items.length === 0 ? (
|
<table className="ltable">
|
||||||
<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>
|
<thead>
|
||||||
<tr style={{ textAlign: "left", borderBottom: "1px solid #eaeaea" }}>
|
<tr>
|
||||||
<th style={th}>When</th>
|
<th>When</th>
|
||||||
<th style={th}>Action</th>
|
<th>Event</th>
|
||||||
<th style={th}>Actor</th>
|
<th>Actor</th>
|
||||||
<th style={th}>Target</th>
|
<th>Product</th>
|
||||||
<th style={th}>Product</th>
|
<th>Source IP</th>
|
||||||
<th style={th}>Meta</th>
|
<th>Result</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{page.items.map((ev) => (
|
{rows.slice(0, 50).map((r, i) => (
|
||||||
<tr key={ev.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
|
<tr key={i}>
|
||||||
<td style={td} title={formatDateTime(ev.created_at)}>
|
<td className="mono t-dim" style={{ whiteSpace: "nowrap" }}>{r.date} {r.time}</td>
|
||||||
{formatRelative(ev.created_at)}
|
<td className="mono" style={{ fontSize: 11.5 }}>{r.event}</td>
|
||||||
</td>
|
<td>{r.actor}</td>
|
||||||
<td style={{ ...td, fontFamily: "ui-monospace, monospace" }}>
|
<td className="mono t-dim">{r.product}</td>
|
||||||
{ev.action}
|
<td className="mono t-dim">{r.ip}</td>
|
||||||
</td>
|
<td>
|
||||||
<td style={td}>
|
<span className="row" style={{ gap: 6, fontSize: 12 }}>
|
||||||
{ev.actor_name || ev.actor_id || (
|
<span className={`dot ${r.result === "denied" ? "danger" : "ok"}`} />
|
||||||
<em style={{ color: "#999" }}>system</em>
|
{r.result === "denied" ? "DENIED" : "OK"}
|
||||||
)}
|
</span>
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: 16, display: "flex", justifyContent: "space-between" }}>
|
|
||||||
<div>
|
|
||||||
{resetHref && (
|
|
||||||
<Link href={resetHref as `/${string}`} style={btnLink}>
|
|
||||||
← Clear filters
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{nextHref && (
|
|
||||||
<Link href={nextHref as `/${string}`} style={btnLink}>
|
|
||||||
Next page →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { 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,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const session = (await auth()) as SessionWithExtras | null;
|
const session = await getPortalSession();
|
||||||
if (!canSee(session, "billing")) return <NotAuthorized />;
|
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 (
|
return (
|
||||||
<ShellEmpty
|
<div className="content-inner" style={{ maxWidth: 1080 }}>
|
||||||
title="Billing"
|
<div className="page-head">
|
||||||
description="Plan, seats, invoices. Polar Checkout opens here for plan changes."
|
<div>
|
||||||
milestone="M8.3"
|
<div className="page-title">Billing</div>
|
||||||
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."
|
<div className="page-sub">Plan, usage, payment method and invoice history</div>
|
||||||
cta={
|
</div>
|
||||||
<Link
|
<div className="ph-actions">
|
||||||
href={`/${slug}/catalog`}
|
<button type="button" className="btn">Download all (PDF)</button>
|
||||||
style={{ color: "#0070f3", fontSize: 14, textDecoration: "underline" }}
|
</div>
|
||||||
>
|
</div>
|
||||||
Browse the catalog →
|
|
||||||
</Link>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,159 @@
|
|||||||
import { redirect } from "next/navigation";
|
import Link from "next/link";
|
||||||
import { auth } from "@/auth";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { NotAuthorized } from "@/components/ShellEmpty";
|
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
|
||||||
import type { SessionWithExtras } from "@/lib/session";
|
|
||||||
import { canSee } from "@/lib/session";
|
import { canSee } from "@/lib/session";
|
||||||
import {
|
import { getPortalSession } from "@/lib/get-session";
|
||||||
fetchCatalog,
|
import { loadTenantForShell } from "@/lib/portal-data";
|
||||||
fetchEntitlements,
|
import { Panel } from "@/components/portal/Panel";
|
||||||
fetchTenantBySlug,
|
import { Monogram } from "@/components/portal/Monogram";
|
||||||
type CatalogEntry,
|
import { Sev } from "@/components/portal/Sev";
|
||||||
type Entitlement,
|
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||||
} from "@/lib/tenant-registry";
|
|
||||||
|
|
||||||
export default async function ProductsPage({
|
export default async function ProductsPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
|
searchParams: Promise<{ p?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const session = (await auth()) as SessionWithExtras | null;
|
const sp = await searchParams;
|
||||||
if (!canSee(session, "products")) return <NotAuthorized />;
|
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);
|
const productFilter = sp.p ?? "all";
|
||||||
if (!tenant) redirect(`/${slug}/dashboard`);
|
const findings = t.findings.filter((f) => productFilter === "all" || f.product === productFilter);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div className="content-inner">
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Products</h1>
|
<div className="page-head">
|
||||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
<div>
|
||||||
Live entitlements for <strong>{tenant.name}</strong>. Open a product to
|
<div className="page-title">Products</div>
|
||||||
use its web component (M6.x / M7.x).
|
<div className="page-sub">
|
||||||
</p>
|
{t.entitled.length} entitled · click a product to open its launch screen
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<ul
|
</div>
|
||||||
style={{
|
|
||||||
listStyle: "none",
|
<div className="product-grid" style={{ marginBottom: 14 }}>
|
||||||
padding: 0,
|
{t.products.map((p) => {
|
||||||
display: "grid",
|
const isLive = p.status === "live";
|
||||||
gap: 12,
|
const entitled = t.entitled.includes(p.id);
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
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));
|
||||||
{active.map((e) => (
|
|
||||||
<ProductCard key={e.product} ent={e} catalog={byKey.get(e.product)} />
|
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>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
<div className="pcard-stats">
|
||||||
</section>
|
<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>
|
||||||
|
|
||||||
function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) {
|
<Panel
|
||||||
return (
|
title="Findings across products"
|
||||||
<li
|
tail={
|
||||||
style={{
|
<span className="row" style={{ gap: 6 }}>
|
||||||
padding: 16,
|
{["all", "compliance-scanner", "certifai"].map((o) => (
|
||||||
border: "1px solid #eaeaea",
|
<Link
|
||||||
borderRadius: 8,
|
key={o}
|
||||||
background: "white",
|
href={o === "all" ? `/${slug}/products` : `/${slug}/products?p=${o}`}
|
||||||
}}
|
className={"btn btn-sm" + (productFilter === o ? " btn-primary" : "")}
|
||||||
>
|
>
|
||||||
<div
|
{o === "all" ? "All" : o === "certifai" ? "CERTifAI" : "Scanner"}
|
||||||
style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}
|
</Link>
|
||||||
>
|
))}
|
||||||
<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)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
}
|
||||||
|
pad={false}
|
||||||
|
>
|
||||||
|
<table className="ltable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sev</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Control</th>
|
||||||
|
<th className="r">Age</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{findings.slice(0, 12).map((f) => (
|
||||||
|
<tr key={f.id} className="clickable">
|
||||||
|
<td><Sev level={f.severity} /></td>
|
||||||
|
<td className="t-id">{f.id}</td>
|
||||||
|
<td style={{ maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{f.title}
|
||||||
|
</td>
|
||||||
|
<td className="mono t-dim">{f.product}</td>
|
||||||
|
<td className="mono t-dim">{f.control}</td>
|
||||||
|
<td className="r mono">{f.ageDays}d</td>
|
||||||
|
<td>
|
||||||
|
<span className="row" style={{ gap: 6, fontSize: 12 }}>
|
||||||
|
<span className={`dot ${f.status === "resolved" ? "ok" : "danger"}`} />
|
||||||
|
{f.status === "resolved" ? "Resolved" : "Open"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Panel>
|
||||||
</div>
|
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 (
|
return (
|
||||||
<ShellEmpty
|
<div className="content-inner" style={{ maxWidth: 1080 }}>
|
||||||
title="Integrations"
|
<div className="page-head">
|
||||||
description="Webhooks, outbound integrations, and external IdP configuration."
|
<div>
|
||||||
milestone="M15.2"
|
<div className="page-title">SSO</div>
|
||||||
details="Webhook delivery (signed payloads, retry-with-backoff, dead-letter queue) lands alongside the headless-product API surface."
|
<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 { 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,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const session = (await auth()) as SessionWithExtras | null;
|
const session = await getPortalSession();
|
||||||
if (!canSee(session, "settings")) return <NotAuthorized />;
|
if (!canSee(session, "settings")) return <NotAllowed need="IT_ADMIN" />;
|
||||||
|
const t = await loadTenantForShell(slug);
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
const tenant = await fetchTenantBySlug(slug);
|
const subscribed = t.products.filter((p) => t.entitled.includes(p.id));
|
||||||
if (!tenant) {
|
const trialing = t.products.filter((p) => t.trialing.includes(p.id));
|
||||||
return (
|
const seatsLeft = t.seats.total - t.seats.used;
|
||||||
<section>
|
const pct = t.seats.total > 0 ? (t.seats.used / t.seats.total) * 100 : 0;
|
||||||
<h1 style={{ fontSize: 28 }}>Settings</h1>
|
|
||||||
<p style={{ color: "#a82626" }}>Tenant not found.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<div className="content-inner" style={{ maxWidth: 1080 }}>
|
||||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Settings</h1>
|
<div className="page-head">
|
||||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
<div>
|
||||||
Tenant identity and lifecycle metadata. Editing these lands in the
|
<div className="page-title">Organization</div>
|
||||||
M10.1 follow-up; for now contact <a href={`/${slug}/support`}>support</a>.
|
<div className="page-sub">
|
||||||
</p>
|
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>
|
<div className="grid g-12">
|
||||||
<Field label="Tenant ID" value={tenant.id} mono />
|
<div className="span-7 col" style={{ gap: 12 }}>
|
||||||
<Field label="Slug" value={tenant.slug} mono />
|
<Panel title="Legal entity" bracket>
|
||||||
<Field label="Name" value={tenant.name} />
|
<dl className="dl">
|
||||||
<Field label="Kind" value={tenant.kind} />
|
<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>
|
<Panel title="Primary contact">
|
||||||
<Field label="Plan" value={tenant.plan} />
|
<div className="row" style={{ gap: 12 }}>
|
||||||
<Field label="Status" value={tenant.status} badge={statusColor(tenant.status)} />
|
<span className="avatar" style={{ width: 38, height: 38, fontSize: 13 }}>
|
||||||
{tenant.trial_ends_at && (
|
{t.contact.split(" ").map((s) => s[0]).join("")}
|
||||||
<Field label="Trial ends at" value={formatDateTime(tenant.trial_ends_at)} mono />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 />
|
|
||||||
|
|
||||||
<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>
|
</span>
|
||||||
) : (
|
<div>
|
||||||
value
|
<div style={{ fontWeight: 600 }}>{t.contact}</div>
|
||||||
)}
|
<div className="mono muted" style={{ fontSize: 12 }}>{t.contactEmail}</div>
|
||||||
|
</div>
|
||||||
|
<span className="spacer" />
|
||||||
|
<span className="tag"><span className="dot accent" /> ADMIN OWNER</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="span-5 col" style={{ gap: 12 }}>
|
||||||
|
<Panel title="Plan & seats">
|
||||||
|
<div className="row between" style={{ alignItems: "flex-start", marginBottom: 14 }}>
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">PLAN</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 600, marginTop: 3 }}>{t.plan}</div>
|
||||||
|
<div className="mono muted" style={{ fontSize: 11 }}>{t.planCode}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div className="eyebrow">RENEWS</div>
|
||||||
|
<div className="mono" style={{ fontSize: 14, marginTop: 3, color: t.renewal === "overdue" ? "var(--danger)" : "inherit" }}>{t.renewal}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row between" style={{ marginBottom: 6 }}>
|
||||||
|
<span className="label-micro">SEATS</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12 }}>{t.seats.used} / {t.seats.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="meter"><span style={{ width: `${pct}%` }} /></div>
|
||||||
|
<div className="muted mono" style={{ fontSize: 10.5, marginTop: 6 }}>{seatsLeft} seats available</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Products subscribed" tail={<span className="label-micro">{subscribed.length} active</span>} pad={false}>
|
||||||
|
<div className="kv-list" style={{ padding: "2px 14px" }}>
|
||||||
|
{subscribed.map((p) => (
|
||||||
|
<div className="kv" key={p.id}>
|
||||||
|
<span className="row" style={{ gap: 9 }}>
|
||||||
|
<Monogram text={p.mono} size={24} />
|
||||||
|
<span className="kvk" style={{ fontWeight: 500, color: "var(--ink)", whiteSpace: "nowrap" }}>{p.name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<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>
|
</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 { 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 (
|
return (
|
||||||
<ShellEmpty
|
<div className="content-inner">
|
||||||
title="Users"
|
<div className="page-head">
|
||||||
description="Invite teammates as IT_ADMIN, CXO, FINANCE, LEGAL, or USER."
|
<div>
|
||||||
milestone="M10.1 follow-up"
|
<div className="page-title">Team</div>
|
||||||
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="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