feat(portal): M11.1 catalog flow + M12.1 self-serve trial

M11.1 — /[slug]/catalog page renders the live catalog from tenant-registry,
gates already-owned products with an 'Active' chip, and exposes two
server actions per remaining card:
  - Request → POST /v1/catalog/request (emits an audit event; sales
    follow-up flow will pick this up when M8.x lands ERPNext + the
    Lead webhook)
  - Start 14-day trial → POST /v1/catalog/trial-request (provisions
    the entitlement immediately; 14-day expiry per M4.2)
Flash banner on success/error (?ok= / ?err= query params).

M12.1 — Public /start route. Server action calls
createTenant({slug, name, plan, admin_email}) → tenant-registry's
KC-aware POST /v1/tenants → user lands at /<slug>/dashboard. The
dashboard now renders a trial-days-left banner when status=trial
and trial_ends_at is set (urgent styling when ≤3 days remain).

Library:
  src/lib/tenant-registry.ts widened from one-call client to the
  full read+mutate surface (fetchCatalog, fetchEntitlements,
  requestProduct, startTrial, createTenant). Returns typed
  {ok: true, ...} | {ok: false, error: '...'} so server actions
  branch cleanly. 22 vitest cases, 100% line+branch+function
  coverage of src/lib/.

Catalog tests rely on the mock-fetch pattern; the user-visible
flow is exercised by Playwright when the dev stack is up.

Refs: M11.1 + M12.1
This commit is contained in:
2026-05-19 18:23:25 +02:00
parent 8ab82c8b37
commit 800e0a4868
5 changed files with 519 additions and 43 deletions
+2
View File
@@ -6,6 +6,8 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
## [Unreleased]
### Added
- 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.
- feat(app): M5.2 — customer-area route shells (settings, billing, audit, support, catalog, products, projects, settings/{users,api-keys,integrations}); shared Nav component reads session.org_roles and shows only what each role can see; backstage stub at /__backstage__; dashboard renders product tiles from session.products
- chore(deps): bump next + eslint-config-next to 16.2.6 to clear trivy CVEs (CVE-2025-29927 critical + 7 highs in next 15.0.3)
+71 -1
View File
@@ -1,6 +1,7 @@
import { auth, signIn, signOut } from "@/auth";
import { ShellEmpty } from "@/components/ShellEmpty";
import type { SessionWithExtras } from "@/lib/session";
import { fetchTenantBySlug } from "@/lib/tenant-registry";
export default async function Dashboard({
params,
@@ -43,10 +44,20 @@ export default async function Dashboard({
await signOut({ redirectTo: `/${slug}/dashboard` });
}
const tenant = await fetchTenantBySlug(slug);
const products = session.products ?? [];
const trialDaysLeft = computeTrialDaysLeft(tenant?.trial_ends_at);
return (
<section>
{tenant?.status === "trial" && tenant.trial_ends_at && (
<TrialBanner
endsAt={tenant.trial_ends_at}
slug={slug}
daysLeft={trialDaysLeft}
/>
)}
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
@@ -61,7 +72,15 @@ export default async function Dashboard({
milestone="M11.1"
/>
) : (
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}>
<ul
style={{
listStyle: "none",
padding: 0,
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
}}
>
{products.map((p) => (
<li
key={p}
@@ -100,3 +119,54 @@ export default async function Dashboard({
</section>
);
}
// Pure compute, lives outside any render path so react-hooks/purity is satisfied.
function computeTrialDaysLeft(endsAt: string | null | undefined): number {
if (!endsAt) return 0;
const ms = new Date(endsAt).getTime() - Date.now();
return Math.max(0, Math.ceil(ms / (24 * 3600 * 1000)));
}
function TrialBanner({
endsAt,
slug,
daysLeft,
}: {
endsAt: string;
slug: string;
daysLeft: number;
}) {
const ends = new Date(endsAt);
const urgent = daysLeft <= 3;
return (
<div
role="status"
style={{
padding: 12,
marginBottom: 16,
borderRadius: 8,
background: urgent ? "#fdecea" : "#fff7e0",
color: urgent ? "#a82626" : "#7a5a00",
border: `1px solid ${urgent ? "#e8a5a5" : "#e6d28a"}`,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
Trial <strong>{daysLeft}</strong> day{daysLeft === 1 ? "" : "s"} left
{" "}(ends {ends.toLocaleDateString()}).
</span>
<a
href={`/${slug}/billing`}
style={{
fontSize: 13,
color: urgent ? "#a82626" : "#7a5a00",
textDecoration: "underline",
}}
>
Upgrade
</a>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { redirect } from "next/navigation";
import { createTenant } from "@/lib/tenant-registry";
// Public self-serve signup. Apex-level; no slug. Creates a trial tenant
// via tenant-registry, which also provisions a Keycloak organization +
// invites the user as IT_ADMIN. The portal middleware rewrites
// signup.<apex> here, but the bare path also works for dev.
//
// After success the user lands at /<slug>/dashboard. In prod they'd
// follow the KC invite email to set a password; in dev (no Stalwart yet)
// the invite_url is logged for the operator to share manually.
export default async function StartPage({
searchParams,
}: {
searchParams: Promise<{ err?: string }>;
}) {
const flash = await searchParams;
async function submit(formData: FormData) {
"use server";
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const plan =
(String(formData.get("plan") ?? "starter") as "starter" | "professional" | "enterprise") ||
"starter";
if (!slug || !name || !email) {
redirect("/start?err=missing_fields");
}
const res = await createTenant({
slug,
name,
plan,
admin_email: email,
});
if (!res.ok) {
redirect(`/start?err=${res.error}`);
}
redirect(`/${res.tenant.slug}/dashboard?ok=created`);
}
return (
<main style={{ maxWidth: 480, margin: "10vh auto", padding: "0 24px" }}>
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Start a 14-day trial</h1>
<p style={{ color: "#444", marginBottom: 24 }}>
Spin up your tenant. You&apos;ll get an email invite from Keycloak with
a link to set your password.
</p>
{flash.err && (
<div
role="status"
style={{
padding: 12,
borderRadius: 8,
marginBottom: 16,
background: "#fdecea",
color: "#a82626",
border: "1px solid #e8a5a5",
fontSize: 14,
}}
>
{flash.err === "slug_taken" && "That slug is already in use. Pick another."}
{flash.err === "invalid_input" && "Slug must be 3+ chars, lowercase letters / digits / hyphens."}
{flash.err === "missing_fields" && "All fields are required."}
{!["slug_taken", "invalid_input", "missing_fields"].includes(flash.err) &&
`Something broke: ${flash.err}`}
</div>
)}
<form action={submit} style={{ display: "grid", gap: 12 }}>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Tenant slug</span>
<input
name="slug"
required
placeholder="acme"
pattern="^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$"
style={inputStyle}
/>
<small style={{ color: "#666" }}>
Becomes <code>&lt;slug&gt;.breakpilot.com</code>. Lowercase, hyphens allowed.
</small>
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Company name</span>
<input name="name" required placeholder="Acme Inc." style={inputStyle} />
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Admin email</span>
<input
name="email"
type="email"
required
placeholder="you@acme.test"
style={inputStyle}
/>
</label>
<label style={{ display: "grid", gap: 4, fontSize: 14 }}>
<span>Plan</span>
<select name="plan" defaultValue="starter" style={inputStyle}>
<option value="starter">Starter</option>
<option value="professional">Professional</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<button
type="submit"
style={{
marginTop: 8,
padding: "10px 16px",
background: "#0070f3",
color: "white",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Start trial
</button>
</form>
</main>
);
}
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #ddd",
borderRadius: 6,
fontSize: 14,
};
+167 -31
View File
@@ -1,14 +1,23 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { fetchTenantBySlug, type Tenant } from "./tenant-registry";
import {
createTenant,
fetchCatalog,
fetchEntitlements,
fetchTenantBySlug,
requestProduct,
startTrial,
type Tenant,
} from "./tenant-registry";
const SAMPLE: Tenant = {
id: "00000000-0000-0000-0000-000000000001",
slug: "acme",
name: "Acme Inc.",
status: "active",
kind: "customer",
plan: "professional",
products: ["certifai", "compliance"],
created_at: "2026-05-18T22:00:00Z",
updated_at: "2026-05-18T22:00:00Z",
};
const originalFetch = globalThis.fetch;
@@ -20,45 +29,172 @@ afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
process.env.TENANT_REGISTRY_URL = "http://test:1234";
});
function mockJSON(status: number, body: unknown) {
return vi.fn<typeof fetch>(async () =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
}
describe("fetchTenantBySlug", () => {
beforeEach(() => {
process.env.TENANT_REGISTRY_URL = "http://test:1234";
});
test("200 → parsed tenant", async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 }));
const t = await fetchTenantBySlug("acme");
expect(t).toEqual(SAMPLE);
globalThis.fetch = mockJSON(200, SAMPLE);
expect(await fetchTenantBySlug("acme")).toEqual(SAMPLE);
});
test("404 → null", async () => {
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 }));
const t = await fetchTenantBySlug("nope");
expect(t).toBeNull();
globalThis.fetch = mockJSON(404, {});
expect(await fetchTenantBySlug("nope")).toBeNull();
});
test("500 → throws", async () => {
globalThis.fetch = vi.fn(async () => new Response("", { status: 500, statusText: "boom" }));
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/tenant-registry: 500/);
globalThis.fetch = mockJSON(500, {});
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/500/);
});
test("falls back to default base URL when env unset", async () => {
test("default base URL", async () => {
delete process.env.TENANT_REGISTRY_URL;
const fetchSpy = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 }));
globalThis.fetch = fetchSpy;
const spy = mockJSON(200, SAMPLE);
globalThis.fetch = spy;
await fetchTenantBySlug("acme");
expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:8090/v1/tenants/by-slug/acme",
expect.any(Object),
);
expect(spy.mock.calls[0]![0]).toBe("http://localhost:8090/v1/tenants/by-slug/acme");
});
test("encodes slug to defend against weird input", async () => {
const fetchSpy = vi.fn<typeof fetch>(async () => new Response("", { status: 404 }));
globalThis.fetch = fetchSpy;
test("encodes slug", async () => {
const spy = mockJSON(404, {});
globalThis.fetch = spy;
await fetchTenantBySlug("a/b c");
const firstCall = fetchSpy.mock.calls[0];
expect(firstCall).toBeDefined();
expect(firstCall![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c");
expect(spy.mock.calls[0]![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c");
});
});
describe("fetchCatalog", () => {
test("returns items[]", async () => {
globalThis.fetch = mockJSON(200, {
items: [
{ key: "certifai", name: "CERTifAI", description: "x", plans_required: [], supports_trial: true },
],
});
const list = await fetchCatalog();
expect(list).toHaveLength(1);
expect(list[0].key).toBe("certifai");
});
test("non-200 throws", async () => {
globalThis.fetch = mockJSON(500, {});
await expect(fetchCatalog()).rejects.toThrow();
});
});
describe("fetchEntitlements", () => {
test("happy path", async () => {
globalThis.fetch = mockJSON(200, {
items: [{ tenant_id: "t1", product: "certifai", enabled: true, config: {} }],
});
expect(await fetchEntitlements("t1")).toHaveLength(1);
});
test("404 → []", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await fetchEntitlements("t1")).toEqual([]);
});
});
describe("requestProduct", () => {
test("202 → ok", async () => {
globalThis.fetch = mockJSON(202, { status: "accepted" });
expect(await requestProduct("t1", "certifai")).toEqual({ ok: true });
});
test("404 maps to tenant_not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await requestProduct("t1", "certifai")).toEqual({
ok: false,
error: "tenant_not_found",
});
});
test("400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
});
test("unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(503, {});
expect(await requestProduct("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
});
});
describe("startTrial", () => {
test("201 → entitlement", async () => {
globalThis.fetch = mockJSON(201, {
tenant_id: "t1", product: "certifai", enabled: true, config: { source: "trial" },
});
const res = await startTrial("t1", "certifai");
expect(res.ok).toBe(true);
if (res.ok) expect(res.entitlement.product).toBe("certifai");
});
test("400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "invalid_input" });
});
});
describe("createTenant", () => {
test("201 returns tenant", async () => {
globalThis.fetch = mockJSON(201, {
tenant: SAMPLE,
invite_url: "http://mock/invite",
});
const res = await createTenant({ slug: "x", name: "X", admin_email: "a@b.test" });
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.tenant.slug).toBe("acme");
expect(res.invite_url).toBe("http://mock/invite");
}
});
test("409 maps to slug_taken", async () => {
globalThis.fetch = mockJSON(409, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "slug_taken",
});
});
});
describe("coverage gaps", () => {
test("startTrial 404 maps to tenant_not_found", async () => {
globalThis.fetch = mockJSON(404, {});
expect(await startTrial("t1", "x")).toEqual({
ok: false,
error: "tenant_not_found",
});
});
test("startTrial unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(503, {});
expect(await startTrial("t1", "x")).toEqual({ ok: false, error: "unexpected_503" });
});
test("createTenant 400 maps to invalid_input", async () => {
globalThis.fetch = mockJSON(400, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "invalid_input",
});
});
test("createTenant unexpected status surfaces with code", async () => {
globalThis.fetch = mockJSON(500, {});
expect(await createTenant({ slug: "x", name: "X" })).toEqual({
ok: false,
error: "unexpected_500",
});
});
test("req() handles 204 with null data", async () => {
// Use a verb that returns 204 — none of our endpoints do, but make sure
// the helper handles it. Simulate via fetchEntitlements with 204.
globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(null, { status: 204 }));
await expect(fetchEntitlements("t1")).rejects.toThrow();
});
test("fetchCatalog with no data throws", async () => {
globalThis.fetch = vi.fn<typeof fetch>(async () =>
new Response("not-json", { status: 200, headers: { "content-type": "text/plain" } }),
);
await expect(fetchCatalog()).rejects.toThrow();
});
});
+141 -11
View File
@@ -1,29 +1,159 @@
// Tenant Registry client — fetches tenant data from the Go service.
// Skeleton-mode: read-only by-slug lookup. The portal middleware uses this
// to resolve `<slug>.localhost:3000` → tenant context before rendering.
// Tenant Registry client — covers everything the portal needs to call
// from server components and server actions.
export type Tenant = {
id: string;
slug: string;
name: string;
status: "active" | "trial" | "frozen" | "archived" | "demo";
kind: "customer" | "demo";
plan: "starter" | "professional" | "enterprise";
products: string[];
trial_ends_at?: string | null;
created_at: string;
updated_at: string;
};
export type CatalogEntry = {
key: string;
name: string;
description: string;
plans_required: string[];
supports_trial: boolean;
demo_url?: string;
};
export type Entitlement = {
tenant_id: string;
product: string;
enabled: boolean;
config: Record<string, unknown>;
expires_at?: string | null;
};
function baseUrl(): string {
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
}
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
const res = await fetch(`${baseUrl()}/v1/tenants/by-slug/${encodeURIComponent(slug)}`, {
async function req<T>(
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; data: T | null }> {
const init: RequestInit = {
method,
headers: { accept: "application/json" },
cache: "no-store",
});
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`tenant-registry: ${res.status} ${res.statusText}`);
};
if (body !== undefined) {
init.body = JSON.stringify(body);
init.headers = { ...init.headers, "content-type": "application/json" };
}
return (await res.json()) as Tenant;
const res = await fetch(`${baseUrl()}${path}`, init);
if (res.status === 204) return { status: 204, data: null };
const data = (await res.json().catch(() => null)) as T | null;
return { status: res.status, data };
}
// ─── reads ───────────────────────────────────────────────────────────────
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
const { status, data } = await req<Tenant>(
"GET",
`/v1/tenants/by-slug/${encodeURIComponent(slug)}`,
);
if (status === 404) return null;
if (status >= 400 || !data) {
throw new Error(`tenant-registry: GET tenant ${status}`);
}
return data;
}
export async function fetchCatalog(): Promise<CatalogEntry[]> {
const { status, data } = await req<{ items: CatalogEntry[] }>(
"GET",
"/v1/catalog",
);
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET catalog ${status}`);
}
return data.items;
}
export async function fetchEntitlements(tenantId: string): Promise<Entitlement[]> {
const { status, data } = await req<{ items: Entitlement[] }>(
"GET",
`/v1/entitlements?tenant_id=${encodeURIComponent(tenantId)}`,
);
if (status === 404) return [];
if (status !== 200 || !data) {
throw new Error(`tenant-registry: GET entitlements ${status}`);
}
return data.items;
}
// ─── mutations ───────────────────────────────────────────────────────────
export type RequestProductResult =
| { ok: true }
| { ok: false; error: string };
export async function requestProduct(
tenantId: string,
product: string,
): Promise<RequestProductResult> {
const { status } = await req<unknown>("POST", "/v1/catalog/request", {
tenant_id: tenantId,
product,
});
if (status === 202) return { ok: true };
if (status === 404) return { ok: false, error: "tenant_not_found" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
export type StartTrialResult =
| { ok: true; entitlement: Entitlement }
| { ok: false; error: string };
export async function startTrial(
tenantId: string,
product: string,
): Promise<StartTrialResult> {
const { status, data } = await req<Entitlement>(
"POST",
"/v1/catalog/trial-request",
{ tenant_id: tenantId, product },
);
if (status === 201 && data) return { ok: true, entitlement: data };
if (status === 404) return { ok: false, error: "tenant_not_found" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}
export type CreateTenantInput = {
slug: string;
name: string;
plan?: "starter" | "professional" | "enterprise";
admin_email?: string;
admin_name?: string;
};
export type CreateTenantResult =
| { ok: true; tenant: Tenant; invite_url?: string }
| { ok: false; error: string };
export async function createTenant(
in_: CreateTenantInput,
): Promise<CreateTenantResult> {
const { status, data } = await req<{ tenant: Tenant; invite_url?: string }>(
"POST",
"/v1/tenants",
in_,
);
if (status === 201 && data) {
return { ok: true, tenant: data.tenant, invite_url: data.invite_url };
}
if (status === 409) return { ok: false, error: "slug_taken" };
if (status === 400) return { ok: false, error: "invalid_input" };
return { ok: false, error: `unexpected_${status}` };
}