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}` }; }