diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb83bf2..d339207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased]
### Added
+- feat(portal): M10.1 — real content for /settings + /settings/api-keys (full CRUD) + /audit (paginated, filterable) + /products (live entitlements). Forward-looking empty states with milestone hooks + CTAs on projects/users/integrations/billing/support.
- feat(signup): M12.1 — public /start form creates a trial tenant via POST /v1/tenants (KC adapter provisions the org + invites the admin); dashboard renders a trial-days-left banner when status=trial
- feat(catalog): M11.1 — /[slug]/catalog renders the live catalog, gates owned products, server-action 'Request' (POST /v1/catalog/request) + 'Start 14-day trial' (POST /v1/catalog/trial-request)
- feat(test): M5.3 — Playwright e2e harness (apex / tenant / dev-stack-health specs). pnpm e2e + make e2e. CI e2e job gated behind RUN_E2E variable until stage exists.
diff --git a/src/app/[slug]/audit/page.tsx b/src/app/[slug]/audit/page.tsx
index 34b451b..12d9d49 100644
--- a/src/app/[slug]/audit/page.tsx
+++ b/src/app/[slug]/audit/page.tsx
@@ -1,16 +1,207 @@
+import Link from "next/link";
+import { redirect } from "next/navigation";
import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
+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";
-export default async function Page() {
+const PAGE_SIZE = 50;
+
+export default async function AuditPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ cursor?: string; action?: string; actor_id?: string }>;
+}) {
+ const { slug } = await params;
+ const q = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "audit")) return ;
+
+ const tenant = await fetchTenantBySlug(slug);
+ if (!tenant) redirect(`/${slug}/dashboard`);
+
+ 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 nextHref = page.next_cursor
+ ? buildHref(slug, { ...q, cursor: String(page.next_cursor) })
+ : null;
+ const resetHref = (q.action || q.actor_id || q.cursor) ? `/${slug}/audit` : null;
+
return (
-
+
+ Audit log
+
+ Every state-changing action emitted by the portal and the products.{" "}
+
+ Retraced-shape schema
+ {" "}
+ — CSV / PDF export lands in M10.2.
+
+
+
+
+ {page.items.length === 0 ? (
+
+ No events match the current filter.
+
+ ) : (
+
+
+
+
+ | When |
+ Action |
+ Actor |
+ Target |
+ Product |
+ Meta |
+
+
+
+ {page.items.map((ev) => (
+
+ |
+ {formatRelative(ev.created_at)}
+ |
+
+ {ev.action}
+ |
+
+ {ev.actor_name || ev.actor_id || (
+ system
+ )}
+ |
+
+ {ev.target_type && (
+ {ev.target_type}:
+ )}{" "}
+ {ev.target_name || ev.target_id || (
+ —
+ )}
+ |
+
+ {ev.product || portal}
+ |
+
+ {ev.metadata && Object.keys(ev.metadata).length > 0
+ ? truncate(JSON.stringify(ev.metadata), 50)
+ : ""}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+ {resetHref && (
+
+ ← Clear filters
+
+ )}
+
+
+ {nextHref && (
+
+ Next page →
+
+ )}
+
+
+
);
}
+
+function Filters({
+ slug,
+ active,
+}: {
+ slug: string;
+ active: { action?: string; actor_id?: string };
+}) {
+ return (
+
+ );
+}
+
+function buildHref(slug: string, q: Record): string {
+ const qs = new URLSearchParams();
+ for (const [k, v] of Object.entries(q)) {
+ if (v) qs.set(k, v);
+ }
+ const s = qs.toString();
+ return s ? `/${slug}/audit?${s}` : `/${slug}/audit`;
+}
+
+const inputStyle: React.CSSProperties = {
+ padding: "8px 10px",
+ border: "1px solid #ddd",
+ borderRadius: 6,
+ fontSize: 14,
+};
+const btnLink: React.CSSProperties = {
+ color: "#0070f3",
+ fontSize: 13,
+ textDecoration: "none",
+};
+const btnSmall: React.CSSProperties = {
+ padding: "4px 10px",
+ background: "white",
+ color: "#0070f3",
+ border: "1px solid #0070f3",
+ borderRadius: 4,
+ fontSize: 12,
+ cursor: "pointer",
+};
+const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 };
+const td: React.CSSProperties = { padding: "8px 10px" };
diff --git a/src/app/[slug]/billing/page.tsx b/src/app/[slug]/billing/page.tsx
index 1e408e1..21b1f32 100644
--- a/src/app/[slug]/billing/page.tsx
+++ b/src/app/[slug]/billing/page.tsx
@@ -1,16 +1,31 @@
+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";
-export default async function Page() {
+export default async function Page({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "billing")) return ;
return (
+ Browse the catalog →
+
+ }
/>
);
}
diff --git a/src/app/[slug]/products/page.tsx b/src/app/[slug]/products/page.tsx
index b8f8977..26d069d 100644
--- a/src/app/[slug]/products/page.tsx
+++ b/src/app/[slug]/products/page.tsx
@@ -1,16 +1,118 @@
+import { redirect } from "next/navigation";
import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
+import { NotAuthorized } from "@/components/ShellEmpty";
+import { formatDateTime, formatRelative } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
+import {
+ fetchCatalog,
+ fetchEntitlements,
+ fetchTenantBySlug,
+ type CatalogEntry,
+ type Entitlement,
+} from "@/lib/tenant-registry";
-export default async function Page() {
+export default async function ProductsPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null;
- if (!canSee(session, "dashboard")) return ;
+ if (!canSee(session, "products")) return ;
+
+ 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);
+
return (
-
+
+ Products
+
+ Live entitlements for {tenant.name}. Open a product to
+ use its web component (M6.x / M7.x).
+
+
+ {active.length === 0 ? (
+
+ ) : (
+
+ {active.map((e) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ProductCard({ ent, catalog }: { ent: Entitlement; catalog?: CatalogEntry }) {
+ return (
+
+
+ {catalog?.name ?? ent.product}
+ {ent.expires_at && (
+
+ trial · {formatRelative(ent.expires_at)}
+
+ )}
+
+ {catalog?.description && (
+
+ {catalog.description}
+
+ )}
+
+ Web component renders here once {ent.product}-dashboard is
+ registered (M6.3 / M7.2).
+
+
);
}
diff --git a/src/app/[slug]/projects/page.tsx b/src/app/[slug]/projects/page.tsx
index 5b1d4c9..44af27c 100644
--- a/src/app/[slug]/projects/page.tsx
+++ b/src/app/[slug]/projects/page.tsx
@@ -9,8 +9,9 @@ export default async function Page() {
return (
);
}
diff --git a/src/app/[slug]/settings/api-keys/page.tsx b/src/app/[slug]/settings/api-keys/page.tsx
index b7e2beb..f4cd30e 100644
--- a/src/app/[slug]/settings/api-keys/page.tsx
+++ b/src/app/[slug]/settings/api-keys/page.tsx
@@ -1,16 +1,266 @@
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
+import { NotAuthorized } from "@/components/ShellEmpty";
+import { formatDateTime, formatRelative, truncate } from "@/lib/format";
import type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
+import {
+ createAPIKey,
+ fetchAPIKeys,
+ fetchCatalog,
+ fetchTenantBySlug,
+ revokeAPIKey,
+ type APIKey,
+} from "@/lib/tenant-registry";
-export default async function Page() {
+export default async function APIKeysPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ plaintext?: string; err?: string }>;
+}) {
+ const { slug } = await params;
+ const flash = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "api-keys")) return ;
+
+ const tenant = await fetchTenantBySlug(slug);
+ if (!tenant) redirect(`/${slug}/dashboard`);
+
+ const [keys, catalog] = await Promise.all([
+ fetchAPIKeys(tenant.id),
+ fetchCatalog(),
+ ]);
+ const active = keys.filter((k) => !k.revoked_at);
+ const revoked = keys.filter((k) => k.revoked_at);
+
+ async function doCreate(formData: FormData) {
+ "use server";
+ const name = String(formData.get("name") ?? "").trim();
+ const product = String(formData.get("product") ?? "").trim();
+ const tenantId = String(formData.get("tenant_id"));
+ const slugV = String(formData.get("slug"));
+ if (!name) redirect(`/${slugV}/settings/api-keys?err=missing_name`);
+
+ const res = await createAPIKey({
+ tenant_id: tenantId,
+ name,
+ product: product || undefined,
+ });
+ if (!res.ok) {
+ redirect(`/${slugV}/settings/api-keys?err=${res.error}`);
+ }
+ revalidatePath(`/${slugV}/settings/api-keys`);
+ redirect(`/${slugV}/settings/api-keys?plaintext=${encodeURIComponent(res.plaintext)}`);
+ }
+
+ async function doRevoke(formData: FormData) {
+ "use server";
+ const id = String(formData.get("id"));
+ const slugV = String(formData.get("slug"));
+ const res = await revokeAPIKey(id);
+ if (!res.ok) {
+ redirect(`/${slugV}/settings/api-keys?err=${res.error}`);
+ }
+ revalidatePath(`/${slugV}/settings/api-keys`);
+ redirect(`/${slugV}/settings/api-keys`);
+ }
+
return (
-
+
+ API keys
+
+ Per-tenant keys for headless product calls. Hashed with argon2id;
+ the plaintext is shown once on creation.
+
+
+ {flash.plaintext && }
+ {flash.err && }
+
+ Create a new key
+
+
+
+ Active keys ({active.length})
+
+ {active.length === 0 ? (
+ No active keys.
+ ) : (
+
+ )}
+
+ {revoked.length > 0 && (
+ <>
+
+ Revoked ({revoked.length})
+
+
+ >
+ )}
+
);
}
+
+function PlaintextBanner({ plaintext }: { plaintext: string }) {
+ return (
+
+
Key created
+
+ Store this value — it cannot be retrieved later.
+
+
+ {plaintext}
+
+
+ );
+}
+
+function ErrorBanner({ err }: { err: string }) {
+ return (
+
+ {err === "name_taken" && "A key with that name already exists."}
+ {err === "missing_name" && "Name is required."}
+ {err === "invalid_input" && "Input failed validation."}
+ {!["name_taken", "missing_name", "invalid_input"].includes(err) && `Error: ${err}`}
+
+ );
+}
+
+function KeyTable({
+ keys,
+ doRevoke,
+ slug,
+ canRevoke,
+}: {
+ keys: APIKey[];
+ doRevoke: (fd: FormData) => Promise;
+ slug: string;
+ canRevoke: boolean;
+}) {
+ return (
+
+
+
+
+ | Name |
+ Prefix |
+ Product |
+ Created |
+ Last used |
+ {canRevoke && | }
+
+
+
+ {keys.map((k) => (
+
+ | {truncate(k.name, 30)} |
+ {k.prefix}… |
+ {k.product || all} |
+
+ {formatRelative(k.created_at)}
+ |
+
+ {k.last_used_at ? formatRelative(k.last_used_at) : never}
+ |
+ {canRevoke && (
+
+
+ |
+ )}
+
+ ))}
+
+
+
+ );
+}
+
+const inputStyle: React.CSSProperties = {
+ padding: "8px 10px",
+ border: "1px solid #ddd",
+ borderRadius: 6,
+ fontSize: 14,
+};
+const btnPrimary: React.CSSProperties = {
+ marginTop: 4,
+ padding: "8px 14px",
+ background: "#0070f3",
+ color: "white",
+ border: "none",
+ borderRadius: 6,
+ fontSize: 14,
+ cursor: "pointer",
+ justifySelf: "start",
+};
+const btnDanger: React.CSSProperties = {
+ padding: "4px 8px",
+ background: "white",
+ color: "#a82626",
+ border: "1px solid #e8a5a5",
+ borderRadius: 4,
+ fontSize: 12,
+ cursor: "pointer",
+};
+const th: React.CSSProperties = { padding: "8px 10px", color: "#666", fontWeight: 500 };
+const td: React.CSSProperties = { padding: "8px 10px" };
diff --git a/src/app/[slug]/settings/integrations/page.tsx b/src/app/[slug]/settings/integrations/page.tsx
index 0db3dc3..b13ead3 100644
--- a/src/app/[slug]/settings/integrations/page.tsx
+++ b/src/app/[slug]/settings/integrations/page.tsx
@@ -9,8 +9,9 @@ export default async function Page() {
return (
);
}
diff --git a/src/app/[slug]/settings/page.tsx b/src/app/[slug]/settings/page.tsx
index fea0707..9d76c9f 100644
--- a/src/app/[slug]/settings/page.tsx
+++ b/src/app/[slug]/settings/page.tsx
@@ -1,16 +1,120 @@
import { auth } from "@/auth";
-import { NotAuthorized, ShellEmpty } from "@/components/ShellEmpty";
+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";
-export default async function Page() {
+export default async function SettingsPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "settings")) return ;
+
+ const tenant = await fetchTenantBySlug(slug);
+ if (!tenant) {
+ return (
+
+ Settings
+ Tenant not found.
+
+ );
+ }
+
return (
-
+
+ Settings
+
+ Tenant identity and lifecycle metadata. Editing these lands in the
+ M10.1 follow-up; for now contact support.
+
+
+ Identity
+
+
+
+
+
+ Plan & status
+
+
+ {tenant.trial_ends_at && (
+
+ )}
+
+ Audit
+
+
+
+ External links
+
+ ERPNext customer + Polar subscription land in M8.3; rendered here when
+ the IDs land on the tenant row.
+
+
);
}
+
+function Field({
+ label,
+ value,
+ mono,
+ badge,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+ badge?: string;
+}) {
+ return (
+
+ {label}
+
+ {badge ? (
+
+ {value}
+
+ ) : (
+ value
+ )}
+
+
+ );
+}
+
+function statusColor(s: string): string {
+ switch (s) {
+ case "active":
+ return "#1a7a3e";
+ case "trial":
+ return "#a87a00";
+ case "frozen":
+ return "#a82626";
+ case "archived":
+ return "#666";
+ case "demo":
+ return "#0070f3";
+ default:
+ return "#444";
+ }
+}
diff --git a/src/app/[slug]/settings/users/page.tsx b/src/app/[slug]/settings/users/page.tsx
index 432ca48..62a3ab2 100644
--- a/src/app/[slug]/settings/users/page.tsx
+++ b/src/app/[slug]/settings/users/page.tsx
@@ -9,8 +9,9 @@ export default async function Page() {
return (
);
}
diff --git a/src/app/[slug]/support/page.tsx b/src/app/[slug]/support/page.tsx
index 67b9f6a..82da799 100644
--- a/src/app/[slug]/support/page.tsx
+++ b/src/app/[slug]/support/page.tsx
@@ -9,8 +9,9 @@ export default async function Page() {
return (
);
}
diff --git a/src/components/ShellEmpty.tsx b/src/components/ShellEmpty.tsx
index c0db025..a1fec1c 100644
--- a/src/components/ShellEmpty.tsx
+++ b/src/components/ShellEmpty.tsx
@@ -1,15 +1,22 @@
-// Reusable empty-state for a customer-area route shell. Every M5.2 route
-// renders one of these; real content lands in M10.1 / M11.x / M12.x /
-// M14.x / etc.
+// Empty state for surfaces whose real backend hasn't shipped yet.
+// `milestone` names the milestone that unblocks the surface; `cta` is an
+// optional in-portal action (link or button) the user can take in the
+// meantime (e.g., "Browse the catalog" while real billing waits on M8.3).
+
+import type { ReactNode } from "react";
export function ShellEmpty({
title,
description,
milestone,
+ details,
+ cta,
}: {
title: string;
description: string;
milestone: string;
+ details?: string;
+ cta?: ReactNode;
}) {
return (
@@ -25,15 +32,18 @@ export function ShellEmpty({
fontSize: 14,
}}
>
- This surface is a route shell. Real implementation lands in{" "}
- {milestone}. See{" "}
-
- PLATFORM_ARCHITECTURE.md §5a
- {" "}
- for the spec.
+
+ {details && {details}
}
+ {cta && {cta}
}
);
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
new file mode 100644
index 0000000..8dc3311
--- /dev/null
+++ b/src/lib/format.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from "vitest";
+import { formatDateTime, formatRelative, truncate } from "./format";
+
+describe("formatRelative", () => {
+ const NOW = new Date("2026-05-20T12:00:00Z").getTime();
+ test("seconds ago", () => {
+ expect(formatRelative("2026-05-20T11:59:50Z", NOW)).toBe("10 seconds ago");
+ });
+ test("singular unit", () => {
+ expect(formatRelative("2026-05-20T11:59:59Z", NOW)).toBe("1 second ago");
+ });
+ test("minutes ago", () => {
+ expect(formatRelative("2026-05-20T11:55:00Z", NOW)).toBe("5 minutes ago");
+ });
+ test("hours ago", () => {
+ expect(formatRelative("2026-05-20T09:00:00Z", NOW)).toBe("3 hours ago");
+ });
+ test("days ago", () => {
+ expect(formatRelative("2026-05-13T12:00:00Z", NOW)).toBe("1 week ago");
+ });
+ test("future", () => {
+ expect(formatRelative("2026-06-03T12:00:00Z", NOW)).toBe("in 2 weeks");
+ });
+ test("malformed input returns the input", () => {
+ expect(formatRelative("not-a-date", NOW)).toBe("not-a-date");
+ });
+ test("years ago", () => {
+ expect(formatRelative("2024-05-20T12:00:00Z", NOW)).toBe("2 years ago");
+ });
+ test("months ago", () => {
+ expect(formatRelative("2026-01-20T12:00:00Z", NOW)).toBe("4 months ago");
+ });
+});
+
+describe("formatDateTime", () => {
+ test("formats UTC", () => {
+ expect(formatDateTime("2026-05-20T12:34:56Z")).toBe("2026-05-20 12:34:56 UTC");
+ });
+ test("malformed input returns input", () => {
+ expect(formatDateTime("nope")).toBe("nope");
+ });
+});
+
+describe("truncate", () => {
+ test("short string unchanged", () => {
+ expect(truncate("hi", 10)).toBe("hi");
+ });
+ test("long string truncated with ellipsis", () => {
+ expect(truncate("abcdefghij", 6)).toBe("abcde…");
+ });
+ test("default max", () => {
+ expect(truncate("x".repeat(50)).length).toBe(40);
+ });
+});
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..7cb7657
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,56 @@
+// Lightweight date/time helpers — shared across server components so we
+// don't reinvent toLocaleString conventions per page.
+
+export function formatRelative(iso: string, now: number = Date.now()): string {
+ const t = new Date(iso).getTime();
+ if (Number.isNaN(t)) return iso;
+ const diff = t - now;
+ const abs = Math.abs(diff);
+ const ago = diff < 0;
+ const units: [string, number][] = [
+ ["second", 1000],
+ ["minute", 60 * 1000],
+ ["hour", 3600 * 1000],
+ ["day", 24 * 3600 * 1000],
+ ["week", 7 * 24 * 3600 * 1000],
+ ["month", 30 * 24 * 3600 * 1000],
+ ["year", 365 * 24 * 3600 * 1000],
+ ];
+ let unit = "second";
+ let n = Math.round(abs / 1000);
+ for (let i = units.length - 1; i >= 0; i--) {
+ if (abs >= units[i][1]) {
+ unit = units[i][0];
+ n = Math.round(abs / units[i][1]);
+ break;
+ }
+ }
+ const suffix = n === 1 ? unit : `${unit}s`;
+ return ago ? `${n} ${suffix} ago` : `in ${n} ${suffix}`;
+}
+
+export function formatDateTime(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ // YYYY-MM-DD HH:MM:SS — locale-stable, sortable, no surprises.
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return (
+ d.getUTCFullYear() +
+ "-" +
+ pad(d.getUTCMonth() + 1) +
+ "-" +
+ pad(d.getUTCDate()) +
+ " " +
+ pad(d.getUTCHours()) +
+ ":" +
+ pad(d.getUTCMinutes()) +
+ ":" +
+ pad(d.getUTCSeconds()) +
+ " UTC"
+ );
+}
+
+export function truncate(s: string, max = 40): string {
+ if (s.length <= max) return s;
+ return s.slice(0, max - 1) + "…";
+}
diff --git a/src/lib/tenant-registry.test.ts b/src/lib/tenant-registry.test.ts
index f311e88..00cf214 100644
--- a/src/lib/tenant-registry.test.ts
+++ b/src/lib/tenant-registry.test.ts
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
+ createAPIKey,
createTenant,
+ fetchAPIKeys,
+ fetchAudit,
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
requestProduct,
+ revokeAPIKey,
startTrial,
type Tenant,
} from "./tenant-registry";
@@ -198,3 +202,119 @@ describe("coverage gaps", () => {
await expect(fetchCatalog()).rejects.toThrow();
});
});
+
+describe("fetchAPIKeys", () => {
+ test("happy path", async () => {
+ globalThis.fetch = mockJSON(200, {
+ items: [
+ { id: "1", tenant_id: "t1", name: "k1", scopes: [], prefix: "bp_a", created_at: "x" },
+ ],
+ });
+ const list = await fetchAPIKeys("t1");
+ expect(list).toHaveLength(1);
+ });
+ test("404 → []", async () => {
+ globalThis.fetch = mockJSON(404, {});
+ expect(await fetchAPIKeys("t1")).toEqual([]);
+ });
+ test("non-200 throws", async () => {
+ globalThis.fetch = mockJSON(500, {});
+ await expect(fetchAPIKeys("t1")).rejects.toThrow();
+ });
+});
+
+describe("createAPIKey", () => {
+ test("201 returns plaintext", async () => {
+ globalThis.fetch = mockJSON(201, {
+ api_key: { id: "1", tenant_id: "t1", name: "k", scopes: [], prefix: "bp_a", created_at: "x" },
+ plaintext: "bp_abc123",
+ });
+ const res = await createAPIKey({ tenant_id: "t1", name: "k" });
+ expect(res.ok).toBe(true);
+ if (res.ok) expect(res.plaintext).toBe("bp_abc123");
+ });
+ test("404 → tenant_not_found", async () => {
+ globalThis.fetch = mockJSON(404, {});
+ expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
+ ok: false,
+ error: "tenant_not_found",
+ });
+ });
+ test("400 → invalid_input", async () => {
+ globalThis.fetch = mockJSON(400, {});
+ expect(await createAPIKey({ tenant_id: "t1", name: "" })).toEqual({
+ ok: false,
+ error: "invalid_input",
+ });
+ });
+ test("409 → name_taken", async () => {
+ globalThis.fetch = mockJSON(409, {});
+ expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
+ ok: false,
+ error: "name_taken",
+ });
+ });
+ test("unexpected status", async () => {
+ globalThis.fetch = mockJSON(500, {});
+ expect(await createAPIKey({ tenant_id: "t1", name: "k" })).toEqual({
+ ok: false,
+ error: "unexpected_500",
+ });
+ });
+});
+
+describe("revokeAPIKey", () => {
+ test("204 → ok", async () => {
+ globalThis.fetch = vi.fn(async () => new Response(null, { status: 204 }));
+ expect(await revokeAPIKey("k1")).toEqual({ ok: true });
+ });
+ test("404 → not_found", async () => {
+ globalThis.fetch = mockJSON(404, {});
+ expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "not_found" });
+ });
+ test("unexpected status", async () => {
+ globalThis.fetch = mockJSON(500, {});
+ expect(await revokeAPIKey("k1")).toEqual({ ok: false, error: "unexpected_500" });
+ });
+});
+
+describe("fetchAudit", () => {
+ test("happy path with filters", async () => {
+ const spy = mockJSON(200, {
+ items: [{ id: 1, action: "tenant.created", created_at: "x" }],
+ next_cursor: 1,
+ });
+ globalThis.fetch = spy;
+ const res = await fetchAudit({
+ tenant_id: "t1",
+ product: "certifai",
+ actor_id: "u1",
+ action: "tenant.created",
+ since: "2026-05-01T00:00:00Z",
+ until: "2026-05-20T00:00:00Z",
+ limit: 50,
+ cursor: 10,
+ });
+ expect(res.items).toHaveLength(1);
+ expect(res.next_cursor).toBe(1);
+ const url = String(spy.mock.calls[0]![0]);
+ expect(url).toContain("tenant_id=t1");
+ expect(url).toContain("product=certifai");
+ expect(url).toContain("actor_id=u1");
+ expect(url).toContain("action=tenant.created");
+ expect(url).toContain("limit=50");
+ expect(url).toContain("cursor=10");
+ });
+ test("no filters", async () => {
+ const spy = mockJSON(200, { items: [] });
+ globalThis.fetch = spy;
+ const res = await fetchAudit({});
+ expect(res.items).toEqual([]);
+ const url = String(spy.mock.calls[0]![0]);
+ expect(url).toBe("http://test:1234/v1/audit?");
+ });
+ test("non-200 throws", async () => {
+ globalThis.fetch = mockJSON(500, {});
+ await expect(fetchAudit({ tenant_id: "t1" })).rejects.toThrow();
+ });
+});
diff --git a/src/lib/tenant-registry.ts b/src/lib/tenant-registry.ts
index cb9c965..1165b41 100644
--- a/src/lib/tenant-registry.ts
+++ b/src/lib/tenant-registry.ts
@@ -30,6 +30,41 @@ export type Entitlement = {
expires_at?: string | null;
};
+export type APIKey = {
+ id: string;
+ tenant_id: string;
+ product?: string;
+ name: string;
+ scopes: string[];
+ prefix: string;
+ created_by?: string;
+ last_used_at?: string | null;
+ revoked_at?: string | null;
+ created_at: string;
+};
+
+export type AuditEvent = {
+ id: number;
+ tenant_id?: string;
+ actor_id?: string;
+ actor_name?: string;
+ actor_type?: string;
+ action: string;
+ target_id?: string;
+ target_type?: string;
+ target_name?: string;
+ product?: string;
+ metadata?: Record;
+ source_ip?: string;
+ user_agent?: string;
+ created_at: string;
+};
+
+export type AuditPage = {
+ items: AuditEvent[];
+ next_cursor?: number;
+};
+
function baseUrl(): string {
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
}
@@ -157,3 +192,79 @@ export async function createTenant(
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
+
+// ─── api keys ────────────────────────────────────────────────────────────
+
+export async function fetchAPIKeys(tenantId: string): Promise {
+ const { status, data } = await req<{ items: APIKey[] }>(
+ "GET",
+ `/v1/api-keys?tenant_id=${encodeURIComponent(tenantId)}`,
+ );
+ if (status === 404) return [];
+ if (status !== 200 || !data) {
+ throw new Error(`tenant-registry: GET api-keys ${status}`);
+ }
+ return data.items;
+}
+
+export type CreateAPIKeyInput = {
+ tenant_id: string;
+ name: string;
+ product?: string;
+ scopes?: string[];
+ created_by?: string;
+};
+
+export type CreateAPIKeyResult =
+ | { ok: true; api_key: APIKey; plaintext: string }
+ | { ok: false; error: string };
+
+export async function createAPIKey(in_: CreateAPIKeyInput): Promise {
+ const { status, data } = await req<{ api_key: APIKey; plaintext: string }>(
+ "POST",
+ "/v1/api-keys",
+ in_,
+ );
+ if (status === 201 && data) return { ok: true, api_key: data.api_key, plaintext: data.plaintext };
+ if (status === 404) return { ok: false, error: "tenant_not_found" };
+ if (status === 400) return { ok: false, error: "invalid_input" };
+ if (status === 409) return { ok: false, error: "name_taken" };
+ return { ok: false, error: `unexpected_${status}` };
+}
+
+export async function revokeAPIKey(id: string): Promise<{ ok: boolean; error?: string }> {
+ const { status } = await req("DELETE", `/v1/api-keys/${encodeURIComponent(id)}`);
+ if (status === 204) return { ok: true };
+ if (status === 404) return { ok: false, error: "not_found" };
+ return { ok: false, error: `unexpected_${status}` };
+}
+
+// ─── audit ───────────────────────────────────────────────────────────────
+
+export type AuditFilter = {
+ tenant_id?: string;
+ product?: string;
+ actor_id?: string;
+ action?: string;
+ since?: string;
+ until?: string;
+ limit?: number;
+ cursor?: number;
+};
+
+export async function fetchAudit(f: AuditFilter): Promise {
+ const qs = new URLSearchParams();
+ if (f.tenant_id) qs.set("tenant_id", f.tenant_id);
+ if (f.product) qs.set("product", f.product);
+ if (f.actor_id) qs.set("actor_id", f.actor_id);
+ if (f.action) qs.set("action", f.action);
+ if (f.since) qs.set("since", f.since);
+ if (f.until) qs.set("until", f.until);
+ if (f.limit) qs.set("limit", String(f.limit));
+ if (f.cursor) qs.set("cursor", String(f.cursor));
+ const { status, data } = await req("GET", `/v1/audit?${qs.toString()}`);
+ if (status !== 200 || !data) {
+ throw new Error(`tenant-registry: GET audit ${status}`);
+ }
+ return data;
+}
diff --git a/tests/e2e/surfaces.spec.ts b/tests/e2e/surfaces.spec.ts
new file mode 100644
index 0000000..6b642e1
--- /dev/null
+++ b/tests/e2e/surfaces.spec.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "@playwright/test";
+
+// One canary per shell surface — confirms the route mounts and renders
+// SOMETHING the user can see (heading or 403 gate) without OIDC.
+// All run signed-out, so role-gated routes land on the NotAuthorized 403.
+
+test.describe("customer-area surfaces @needs-stack", () => {
+ const surfaces = [
+ { path: "/products", expected: "403" },
+ { path: "/projects", expected: "403" },
+ { path: "/catalog", expected: "403" },
+ { path: "/settings", expected: "403" },
+ { path: "/settings/users", expected: "403" },
+ { path: "/settings/api-keys", expected: "403" },
+ { path: "/settings/integrations", expected: "403" },
+ { path: "/billing", expected: "403" },
+ { path: "/audit", expected: "403" },
+ { path: "/support", expected: "403" },
+ ];
+ for (const { path, expected } of surfaces) {
+ test(`acme${path} renders signed-out`, async ({ page }) => {
+ await page.goto(path);
+ await expect(page.getByRole("heading", { name: new RegExp(expected, "i") })).toBeVisible();
+ });
+ }
+});