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:
@@ -6,6 +6,8 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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(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
|
- 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)
|
- 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,6 +1,7 @@
|
|||||||
import { auth, signIn, signOut } from "@/auth";
|
import { auth, signIn, signOut } from "@/auth";
|
||||||
import { ShellEmpty } from "@/components/ShellEmpty";
|
import { ShellEmpty } from "@/components/ShellEmpty";
|
||||||
import type { SessionWithExtras } from "@/lib/session";
|
import type { SessionWithExtras } from "@/lib/session";
|
||||||
|
import { fetchTenantBySlug } from "@/lib/tenant-registry";
|
||||||
|
|
||||||
export default async function Dashboard({
|
export default async function Dashboard({
|
||||||
params,
|
params,
|
||||||
@@ -43,10 +44,20 @@ export default async function Dashboard({
|
|||||||
await signOut({ redirectTo: `/${slug}/dashboard` });
|
await signOut({ redirectTo: `/${slug}/dashboard` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenant = await fetchTenantBySlug(slug);
|
||||||
const products = session.products ?? [];
|
const products = session.products ?? [];
|
||||||
|
const trialDaysLeft = computeTrialDaysLeft(tenant?.trial_ends_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<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>
|
<h1 style={{ fontSize: 28, marginBottom: 8 }}>Dashboard</h1>
|
||||||
<p style={{ color: "#444", marginBottom: 24 }}>
|
<p style={{ color: "#444", marginBottom: 24 }}>
|
||||||
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
|
Welcome, {session.user?.name ?? session.user?.email ?? "user"}. Signed in
|
||||||
@@ -61,7 +72,15 @@ export default async function Dashboard({
|
|||||||
milestone="M11.1"
|
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) => (
|
{products.map((p) => (
|
||||||
<li
|
<li
|
||||||
key={p}
|
key={p}
|
||||||
@@ -100,3 +119,54 @@ export default async function Dashboard({
|
|||||||
</section>
|
</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 { 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 = {
|
const SAMPLE: Tenant = {
|
||||||
id: "00000000-0000-0000-0000-000000000001",
|
id: "00000000-0000-0000-0000-000000000001",
|
||||||
slug: "acme",
|
slug: "acme",
|
||||||
name: "Acme Inc.",
|
name: "Acme Inc.",
|
||||||
status: "active",
|
status: "active",
|
||||||
|
kind: "customer",
|
||||||
plan: "professional",
|
plan: "professional",
|
||||||
products: ["certifai", "compliance"],
|
|
||||||
created_at: "2026-05-18T22:00:00Z",
|
created_at: "2026-05-18T22:00:00Z",
|
||||||
|
updated_at: "2026-05-18T22:00:00Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -20,45 +29,172 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
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", () => {
|
describe("fetchTenantBySlug", () => {
|
||||||
beforeEach(() => {
|
|
||||||
process.env.TENANT_REGISTRY_URL = "http://test:1234";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 → parsed tenant", async () => {
|
test("200 → parsed tenant", async () => {
|
||||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 }));
|
globalThis.fetch = mockJSON(200, SAMPLE);
|
||||||
const t = await fetchTenantBySlug("acme");
|
expect(await fetchTenantBySlug("acme")).toEqual(SAMPLE);
|
||||||
expect(t).toEqual(SAMPLE);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("404 → null", async () => {
|
test("404 → null", async () => {
|
||||||
globalThis.fetch = vi.fn(async () => new Response("", { status: 404 }));
|
globalThis.fetch = mockJSON(404, {});
|
||||||
const t = await fetchTenantBySlug("nope");
|
expect(await fetchTenantBySlug("nope")).toBeNull();
|
||||||
expect(t).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("500 → throws", async () => {
|
test("500 → throws", async () => {
|
||||||
globalThis.fetch = vi.fn(async () => new Response("", { status: 500, statusText: "boom" }));
|
globalThis.fetch = mockJSON(500, {});
|
||||||
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/tenant-registry: 500/);
|
await expect(fetchTenantBySlug("acme")).rejects.toThrow(/500/);
|
||||||
});
|
});
|
||||||
|
test("default base URL", async () => {
|
||||||
test("falls back to default base URL when env unset", async () => {
|
|
||||||
delete process.env.TENANT_REGISTRY_URL;
|
delete process.env.TENANT_REGISTRY_URL;
|
||||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify(SAMPLE), { status: 200 }));
|
const spy = mockJSON(200, SAMPLE);
|
||||||
globalThis.fetch = fetchSpy;
|
globalThis.fetch = spy;
|
||||||
await fetchTenantBySlug("acme");
|
await fetchTenantBySlug("acme");
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
expect(spy.mock.calls[0]![0]).toBe("http://localhost:8090/v1/tenants/by-slug/acme");
|
||||||
"http://localhost:8090/v1/tenants/by-slug/acme",
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
test("encodes slug", async () => {
|
||||||
test("encodes slug to defend against weird input", async () => {
|
const spy = mockJSON(404, {});
|
||||||
const fetchSpy = vi.fn<typeof fetch>(async () => new Response("", { status: 404 }));
|
globalThis.fetch = spy;
|
||||||
globalThis.fetch = fetchSpy;
|
|
||||||
await fetchTenantBySlug("a/b c");
|
await fetchTenantBySlug("a/b c");
|
||||||
const firstCall = fetchSpy.mock.calls[0];
|
expect(spy.mock.calls[0]![0]).toBe("http://test:1234/v1/tenants/by-slug/a%2Fb%20c");
|
||||||
expect(firstCall).toBeDefined();
|
});
|
||||||
expect(firstCall![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.
|
// Tenant Registry client — covers everything the portal needs to call
|
||||||
// Skeleton-mode: read-only by-slug lookup. The portal middleware uses this
|
// from server components and server actions.
|
||||||
// to resolve `<slug>.localhost:3000` → tenant context before rendering.
|
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "active" | "trial" | "frozen" | "archived" | "demo";
|
status: "active" | "trial" | "frozen" | "archived" | "demo";
|
||||||
|
kind: "customer" | "demo";
|
||||||
plan: "starter" | "professional" | "enterprise";
|
plan: "starter" | "professional" | "enterprise";
|
||||||
products: string[];
|
trial_ends_at?: string | null;
|
||||||
created_at: string;
|
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 {
|
function baseUrl(): string {
|
||||||
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
|
return process.env.TENANT_REGISTRY_URL ?? "http://localhost:8090";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTenantBySlug(slug: string): Promise<Tenant | null> {
|
async function req<T>(
|
||||||
const res = await fetch(`${baseUrl()}/v1/tenants/by-slug/${encodeURIComponent(slug)}`, {
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<{ status: number; data: T | null }> {
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json" },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
};
|
||||||
if (res.status === 404) return null;
|
if (body !== undefined) {
|
||||||
if (!res.ok) {
|
init.body = JSON.stringify(body);
|
||||||
throw new Error(`tenant-registry: ${res.status} ${res.statusText}`);
|
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