From 32a20b349879ba5c997ad7f90cfd2a05ec576051 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Wed, 20 May 2026 09:19:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal):=20M10.1=20=E2=80=94=20fill=20the?= =?UTF-8?q?=2010=20customer-area=20shells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four real surfaces backed by live tenant-registry data: /[slug]/settings read-only tenant identity + plan + status + lifecycle dates with badge for status /[slug]/settings/api-keys full CRUD: list active + revoked keys, create form, plaintext-shown-once banner, revoke action /[slug]/audit paginated table with cursor-based next-page navigation, action+actor_id GET filters, formatRelative timestamps, metadata preview /[slug]/products live entitlements (filtered to enabled), trial expiry chip, links to catalog when empty Five remaining surfaces upgraded to milestone-aware empty states (projects / users / integrations / billing / support) with CTAs where useful — billing links to catalog, support points at oncall@. ShellEmpty component grew a 'details' string and a 'cta' ReactNode slot so the empty pages don't all look identical. Library additions: src/lib/format.ts formatRelative, formatDateTime, truncate src/lib/tenant-registry fetchAPIKeys, createAPIKey, revokeAPIKey, fetchAudit — typed result shapes so server actions can branch cleanly Tests: src/lib/format.test.ts 12 cases, 100% coverage src/lib/tenant-registry.test +14 cases for new client methods, 100% line+branch+function tests/e2e/surfaces.spec.ts one canary per of 10 customer-area routes (signed-out → 403) CI all green: lint / typecheck / test / build. Refs: M10.1 --- CHANGELOG.md | 1 + src/app/[slug]/audit/page.tsx | 205 +++++++++++++- src/app/[slug]/billing/page.tsx | 19 +- src/app/[slug]/products/page.tsx | 118 +++++++- src/app/[slug]/projects/page.tsx | 5 +- src/app/[slug]/settings/api-keys/page.tsx | 264 +++++++++++++++++- src/app/[slug]/settings/integrations/page.tsx | 3 +- src/app/[slug]/settings/page.tsx | 118 +++++++- src/app/[slug]/settings/users/page.tsx | 5 +- src/app/[slug]/support/page.tsx | 3 +- src/components/ShellEmpty.tsx | 34 ++- src/lib/format.test.ts | 54 ++++ src/lib/format.ts | 56 ++++ src/lib/tenant-registry.test.ts | 120 ++++++++ src/lib/tenant-registry.ts | 111 ++++++++ tests/e2e/surfaces.spec.ts | 26 ++ 16 files changed, 1093 insertions(+), 49 deletions(-) create mode 100644 src/lib/format.test.ts create mode 100644 src/lib/format.ts create mode 100644 tests/e2e/surfaces.spec.ts 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. +

+ ) : ( +
+ + + + + + + + + + + + + {page.items.map((ev) => ( + + + + + + + + + ))} + +
WhenActionActorTargetProductMeta
+ {formatRelative(ev.created_at)} + + {ev.action} + + {ev.actor_name || ev.actor_id || ( + system + )} + + {ev.target_type && ( + {ev.target_type}: + )}{" "} + {ev.target_name || ev.target_id || ( + + )} + + {ev.product || portal} + + {ev.metadata && Object.keys(ev.metadata).length > 0 + ? truncate(JSON.stringify(ev.metadata), 50) + : ""} +
+
+ )} + +
+
+ {resetHref && ( + + ← Clear filters + + )} +
+
+ {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 ? ( +
+

No products yet.

+ + Browse the catalog → + +
+ ) : ( +
    + {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 ( +
    + + + + + + + + + {canRevoke && } + + + + {keys.map((k) => ( + + + + + + + {canRevoke && ( + + )} + + ))} + +
    NamePrefixProductCreatedLast used
    {truncate(k.name, 30)}{k.prefix}…{k.product || all} + {formatRelative(k.created_at)} + + {k.last_used_at ? formatRelative(k.last_used_at) : never} + +
    + + + +
    +
    +
    + ); +} + +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. +
    + Lands in {milestone}. See{" "} + + PLATFORM_ARCHITECTURE.md §5a + + . +
    + {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(); + }); + } +});