diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc4c30d..bb83bf2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/app/[slug]/catalog/page.tsx b/src/app/[slug]/catalog/page.tsx
index 91f7651..e52885d 100644
--- a/src/app/[slug]/catalog/page.tsx
+++ b/src/app/[slug]/catalog/page.tsx
@@ -1,16 +1,205 @@
+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 type { SessionWithExtras } from "@/lib/session";
import { canSee } from "@/lib/session";
+import {
+ fetchCatalog,
+ fetchEntitlements,
+ fetchTenantBySlug,
+ requestProduct,
+ startTrial,
+ type CatalogEntry,
+} from "@/lib/tenant-registry";
-export default async function Page() {
+export default async function CatalogPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ ok?: string; err?: string }>;
+}) {
+ const { slug } = await params;
+ const flash = await searchParams;
const session = (await auth()) as SessionWithExtras | null;
if (!canSee(session, "catalog")) return ;
+
+ const tenant = await fetchTenantBySlug(slug);
+ if (!tenant) redirect(`/${slug}/dashboard`);
+
+ const [catalog, entitlements] = await Promise.all([
+ fetchCatalog(),
+ fetchEntitlements(tenant.id),
+ ]);
+ const enabled = new Set(entitlements.filter((e) => e.enabled).map((e) => e.product));
+
+ async function doRequest(formData: FormData) {
+ "use server";
+ const product = String(formData.get("product"));
+ const tenantId = String(formData.get("tenant_id"));
+ const slugV = String(formData.get("slug"));
+ const res = await requestProduct(tenantId, product);
+ const param = res.ok ? `ok=requested:${product}` : `err=${res.error}`;
+ redirect(`/${slugV}/catalog?${param}`);
+ }
+
+ async function doTrial(formData: FormData) {
+ "use server";
+ const product = String(formData.get("product"));
+ const tenantId = String(formData.get("tenant_id"));
+ const slugV = String(formData.get("slug"));
+ const res = await startTrial(tenantId, product);
+ const param = res.ok ? `ok=trial:${product}` : `err=${res.error}`;
+ revalidatePath(`/${slugV}/catalog`);
+ redirect(`/${slugV}/catalog?${param}`);
+ }
+
return (
-
+
+ Catalog
+
+ Pick a product to add to your plan. Trial-eligible products start a
+ 14-day evaluation; everything else opens a CRM lead for sales follow-up.
+
+
+
+
+
+ {catalog.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+function FlashBanner({ ok, err }: { ok?: string; err?: string }) {
+ if (!ok && !err) return null;
+ const isOk = !!ok;
+ return (
+
+ {isOk ? `OK — ${ok}` : `Error — ${err}`}
+
+ );
+}
+
+function CatalogCard({
+ product,
+ owned,
+ tenantId,
+ slug,
+ doRequest,
+ doTrial,
+}: {
+ product: CatalogEntry;
+ owned: boolean;
+ tenantId: string;
+ slug: string;
+ doRequest: (fd: FormData) => Promise;
+ doTrial: (fd: FormData) => Promise;
+}) {
+ return (
+
+ {product.name}
+
+ {product.description}
+
+
+ Plans: {product.plans_required.join(", ")}
+
+
+ {owned ? (
+
+ Active
+
+ ) : (
+
+
+
+ {product.supports_trial && (
+
+ )}
+
+ )}
+
);
}
diff --git a/src/app/[slug]/dashboard/page.tsx b/src/app/[slug]/dashboard/page.tsx
index f413124..59e11b1 100644
--- a/src/app/[slug]/dashboard/page.tsx
+++ b/src/app/[slug]/dashboard/page.tsx
@@ -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 (
+ {tenant?.status === "trial" && tenant.trial_ends_at && (
+
+ )}
+
Dashboard
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
@@ -61,7 +72,15 @@ export default async function Dashboard({
milestone="M11.1"
/>
) : (
-
+
{products.map((p) => (
-
);
}
+
+// 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 (
+
+
+ Trial — {daysLeft} day{daysLeft === 1 ? "" : "s"} left
+ {" "}(ends {ends.toLocaleDateString()}).
+
+
+ Upgrade →
+
+
+ );
+}
diff --git a/src/app/start/page.tsx b/src/app/start/page.tsx
new file mode 100644
index 0000000..fff0a8a
--- /dev/null
+++ b/src/app/start/page.tsx
@@ -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. here, but the bare path also works for dev.
+//
+// After success the user lands at //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 (
+
+ Start a 14-day trial
+
+ Spin up your tenant. You'll get an email invite from Keycloak with
+ a link to set your password.
+
+
+ {flash.err && (
+
+ {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}`}
+
+ )}
+
+
+
+ );
+}
+
+const inputStyle: React.CSSProperties = {
+ padding: "8px 10px",
+ border: "1px solid #ddd",
+ borderRadius: 6,
+ fontSize: 14,
+};
diff --git a/src/lib/tenant-registry.test.ts b/src/lib/tenant-registry.test.ts
index 679641d..f311e88 100644
--- a/src/lib/tenant-registry.test.ts
+++ b/src/lib/tenant-registry.test.ts
@@ -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(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(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(async () => new Response(null, { status: 204 }));
+ await expect(fetchEntitlements("t1")).rejects.toThrow();
+ });
+ test("fetchCatalog with no data throws", async () => {
+ globalThis.fetch = vi.fn(async () =>
+ new Response("not-json", { status: 200, headers: { "content-type": "text/plain" } }),
+ );
+ await expect(fetchCatalog()).rejects.toThrow();
});
});
diff --git a/src/lib/tenant-registry.ts b/src/lib/tenant-registry.ts
index 60ed23c..cb9c965 100644
--- a/src/lib/tenant-registry.ts
+++ b/src/lib/tenant-registry.ts
@@ -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 `.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;
+ expires_at?: string | null;
};
function baseUrl(): string {
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
}
-export async function fetchTenantBySlug(slug: string): Promise {
- const res = await fetch(`${baseUrl()}/v1/tenants/by-slug/${encodeURIComponent(slug)}`, {
+async function req(
+ 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 {
+ const { status, data } = await req(
+ "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 {
+ 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 {
+ 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 {
+ const { status } = await req("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 {
+ const { status, data } = await req(
+ "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 {
+ 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}` };
}