feat(portal): M11.1 catalog flow + M12.1 self-serve trial
Closes the customer loop: /start signup → tenant + KC org + IT_ADMIN invite → portal dashboard with trial banner → /[slug]/catalog with Request + Start trial server actions wired to tenant-registry. Refs: M11.1 + M12.1
This commit was merged in pull request #11.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <NotAuthorized />;
|
||||
|
||||
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 (
|
||||
<ShellEmpty
|
||||
title="Catalog"
|
||||
description="Products you can add to your subscription."
|
||||
milestone="M11.1"
|
||||
/>
|
||||
<section>
|
||||
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Catalog</h1>
|
||||
<p style={{ color: "#444", marginBottom: 16 }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<FlashBanner ok={flash.ok} err={flash.err} />
|
||||
|
||||
<ul
|
||||
style={{
|
||||
listStyle: "none",
|
||||
padding: 0,
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{catalog.map((p) => (
|
||||
<CatalogCard
|
||||
key={p.key}
|
||||
product={p}
|
||||
owned={enabled.has(p.key)}
|
||||
tenantId={tenant.id}
|
||||
slug={slug}
|
||||
doRequest={doRequest}
|
||||
doTrial={doTrial}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FlashBanner({ ok, err }: { ok?: string; err?: string }) {
|
||||
if (!ok && !err) return null;
|
||||
const isOk = !!ok;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: isOk ? "#e6f7ec" : "#fdecea",
|
||||
color: isOk ? "#0a6e2a" : "#a82626",
|
||||
border: `1px solid ${isOk ? "#a4d8b8" : "#e8a5a5"}`,
|
||||
}}
|
||||
>
|
||||
{isOk ? `OK — ${ok}` : `Error — ${err}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({
|
||||
product,
|
||||
owned,
|
||||
tenantId,
|
||||
slug,
|
||||
doRequest,
|
||||
doTrial,
|
||||
}: {
|
||||
product: CatalogEntry;
|
||||
owned: boolean;
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
doRequest: (fd: FormData) => Promise<void>;
|
||||
doTrial: (fd: FormData) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
padding: 16,
|
||||
border: "1px solid #eaeaea",
|
||||
borderRadius: 8,
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: 15 }}>{product.name}</strong>
|
||||
<p style={{ color: "#666", fontSize: 13, marginTop: 4, marginBottom: 12 }}>
|
||||
{product.description}
|
||||
</p>
|
||||
<div style={{ fontSize: 12, color: "#666", marginBottom: 12 }}>
|
||||
Plans: {product.plans_required.join(", ")}
|
||||
</div>
|
||||
|
||||
{owned ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: "4px 8px",
|
||||
background: "#eef",
|
||||
borderRadius: 4,
|
||||
color: "#226",
|
||||
}}
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<form action={doRequest}>
|
||||
<input type="hidden" name="product" value={product.key} />
|
||||
<input type="hidden" name="tenant_id" value={tenantId} />
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
background: "white",
|
||||
color: "#0070f3",
|
||||
border: "1px solid #0070f3",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Request
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{product.supports_trial && (
|
||||
<form action={doTrial}>
|
||||
<input type="hidden" name="product" value={product.key} />
|
||||
<input type="hidden" name="tenant_id" value={tenantId} />
|
||||
<input type="hidden" name="slug" value={slug} />
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
background: "#0070f3",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Start 14-day trial
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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><slug>.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
@@ -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
@@ -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}` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user