From b7149c5dda343984051a83ab5ad2364f0d2faab4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 20 Apr 2026 09:47:06 -0700 Subject: [PATCH] feat(canvas): /waitlist page with contact form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing half of the beta-gate: a page at /waitlist that the CP auth callback redirects users to when their email isn't on the allowlist. Collects email + optional name + use-case and POSTs to /cp/waitlist/request (backend landed in controlplane #150). ## Behavior - No auto-pre-fill of email from URL query (CP's #145 dropped the ?email= param for the privacy reason; this test guards against a future regression on the client side). - Client-side validates email shape for instant feedback; backend re-validates. - Three UI states after submit: success → "your request is in" banner, form hidden dedup → softer "already on file" banner when backend returns dedup=true (same 200, no 409 to avoid enumeration) error → inline banner with backend message or network fallback ## Tests 9 tests in __tests__/waitlist-page.test.tsx covering: - default render + a11y (role=button, role=status, role=alert) - URL-pre-fill privacy regression guard - HTML5 + JS validation (empty, malformed) - successful POST with trimmed body - dedup branch - non-2xx with + without error field - network rejection Follow-up to the beta-gate rollout on controlplane #145 / #150. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../waitlist/__tests__/waitlist-page.test.tsx | 192 ++++++++++++++++++ canvas/src/app/waitlist/page.tsx | 189 +++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx create mode 100644 canvas/src/app/waitlist/page.tsx diff --git a/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx b/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx new file mode 100644 index 00000000..a0279708 --- /dev/null +++ b/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +/** + * Tests for /waitlist — the contact form page shown to users rejected + * by the beta-gate (PR #150 backend + #? frontend). + * + * This page is a user's ONLY path to request access after the CP + * rejects their login, so regressions here strand every new user. + * Covers: + * - Form renders with required email field + * - Client-side validation rejects empty / malformed emails + * - Successful POST → success banner, form hidden + * - dedup=true response → softer "already on file" banner + * - Non-2xx response → error banner with server message + * - Network error → error banner with fallback message + * - email NEVER appears in URL (regression guard — pre-fix the CP + * redirect passed ?email= which leaked to referrer headers) + * - Body is normalized (trim email) before submit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from "@testing-library/react"; + +vi.mock("@/lib/api", () => ({ + PLATFORM_URL: "https://cp.test", +})); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch as unknown as typeof fetch; + +import WaitlistPage from "../page"; + +function okJson(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset URL so the URL-leak guard sees a clean query string. + window.history.pushState({}, "", "/waitlist"); +}); + +afterEach(() => { + cleanup(); +}); + +describe("/waitlist — page render", () => { + it("renders the form with an email field by default", () => { + render(); + expect(screen.getByRole("heading", { name: /waitlist/i })).toBeTruthy(); + expect(screen.getByLabelText(/email/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /request access/i })).toBeTruthy(); + // No success/dedup banners at rest. + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("does NOT pre-fill email from URL query (privacy regression guard)", () => { + // Pre-fix, the CP redirected to /waitlist?email=. + // Even though the backend no longer does that, a bookmark or a + // cached redirect could still hand us one. The page must not + // auto-read it. + window.history.pushState({}, "", "/waitlist?email=leaked@example.com"); + render(); + const input = screen.getByLabelText(/email/i) as HTMLInputElement; + expect(input.value).toBe(""); + }); +}); + +describe("/waitlist — client-side validation", () => { + it("rejects an empty email without calling the API", async () => { + render(); + fireEvent.submit(screen.getByRole("button", { name: /request access/i }).closest("form")!); + // HTML5 required attribute handles this before our JS runs — so + // no fetch happens. This test documents the contract that invalid + // submissions never hit the network. + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects a malformed email with an inline error and no fetch", async () => { + render(); + // Type something that passes HTML5 required but fails our @-check. + // We bypass input validation by setting the value directly through + // the onChange handler (jsdom's native email type accepts "noat"). + const input = screen.getByLabelText(/email/i); + fireEvent.change(input, { target: { value: "noat" } }); + const form = screen.getByRole("button", { name: /request access/i }).closest("form")!; + fireEvent.submit(form); + await waitFor(() => { + expect(screen.getByRole("alert")).toBeTruthy(); + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("/waitlist — submit happy path", () => { + it("POSTs a trimmed body and shows a success banner on {ok:true}", async () => { + mockFetch.mockResolvedValueOnce(okJson({ ok: true, id: "req-abc" })); + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: " hi@example.com " }, + }); + fireEvent.change(screen.getByLabelText(/^name$/i), { + target: { value: "Hongming" }, + }); + fireEvent.change(screen.getByLabelText(/what would you build/i), { + target: { value: "research automation" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // URL and body shape. + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://cp.test/cp/waitlist/request"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + email: "hi@example.com", // trimmed + name: "Hongming", + use_case: "research automation", + }); + + await waitFor(() => { + expect(screen.getByRole("status").textContent).toMatch(/your request is in/i); + }); + }); +}); + +describe("/waitlist — submit dedup", () => { + it("shows a softer banner when backend returns dedup=true", async () => { + mockFetch.mockResolvedValueOnce(okJson({ ok: true, dedup: true })); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "existing@example.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("status").textContent).toMatch(/already have your request/i); + }); + }); +}); + +describe("/waitlist — submit error paths", () => { + it("shows the server's error message on a non-2xx response", async () => { + mockFetch.mockResolvedValueOnce(okJson({ error: "email required" }, 400)); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/email required/i); + }); + }); + + it("falls back to a generic message when the response has no error field", async () => { + mockFetch.mockResolvedValueOnce( + okJson({}, 500) // no `error` key + ); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/500/); + }); + }); + + it("shows an error banner on network failure", async () => { + mockFetch.mockRejectedValueOnce(new Error("TCP reset")); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/TCP reset/); + }); + }); +}); diff --git a/canvas/src/app/waitlist/page.tsx b/canvas/src/app/waitlist/page.tsx new file mode 100644 index 00000000..94f95828 --- /dev/null +++ b/canvas/src/app/waitlist/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +// /waitlist — the page shown to users whose email isn't on the +// private-beta allowlist. The CP auth callback redirects here (no +// session cookie set) after rejecting the sign-in. +// +// The page offers a contact form that POSTs to +// /cp/waitlist/request with the user's email + optional name and +// use-case. The CP stores the row in beta_requests; ops triages +// via GET /cp/admin/beta-requests and moves approved emails over +// to beta_allowlist manually. +// +// No session required — the whole point is that the user isn't +// authenticated yet. Per CLAUDE.md privacy rule, we don't read the +// email from a URL query param; user re-enters it into the form. + +import { useState } from "react"; +import { PLATFORM_URL } from "@/lib/api"; + +type SubmitState = "idle" | "submitting" | "success" | "dedup" | "error"; + +interface SubmitResponse { + ok: boolean; + id?: string; + dedup?: boolean; + error?: string; +} + +export default function WaitlistPage() { + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [useCase, setUseCase] = useState(""); + const [state, setState] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(""); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + // Client-side sanity. CP enforces the same checks — these exist + // so the user gets instant feedback without a round trip. + const trimmed = email.trim(); + if (!trimmed || !trimmed.includes("@")) { + setState("error"); + setErrorMsg("Please enter a valid email address."); + return; + } + setState("submitting"); + setErrorMsg(""); + try { + const res = await fetch(`${PLATFORM_URL}/cp/waitlist/request`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: trimmed, + name: name.trim(), + use_case: useCase.trim(), + }), + }); + const body = (await res.json().catch(() => ({}))) as SubmitResponse; + if (!res.ok || !body.ok) { + setState("error"); + setErrorMsg(body.error || `Request failed (${res.status}). Please try again.`); + return; + } + // Backend returns dedup=true when this email was already + // submitted within the last hour. Same 200, softer message. + setState(body.dedup ? "dedup" : "success"); + } catch (err) { + setState("error"); + setErrorMsg(err instanceof Error ? err.message : "Network error. Please try again."); + } + } + + return ( +
+
+

+ You’re on the waitlist +

+

+ Molecule AI is in private beta while we harden the platform. Tell us + a bit about yourself and we’ll reach out when a spot opens. +

+ + {state === "success" && ( +
+ Thanks — your request is in. We’ll email{" "} + {email} when access opens up. +
+ )} + + {state === "dedup" && ( +
+ We already have your request on file for{" "} + {email}. No need to resubmit — + we’ll be in touch. +
+ )} + + {state !== "success" && state !== "dedup" && ( +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="you@company.com" + autoComplete="email" + /> +
+ +
+ + setName(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="How should we address you?" + autoComplete="name" + maxLength={200} + /> +
+ +
+ +