From 7a2a17591c3c22fffb28b90f60bd8d056e889679 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 20 Apr 2026 09:55:35 -0700 Subject: [PATCH] chore(canvas): remove dead /waitlist page (lives in molecule-app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1080 added /waitlist to canvas, but canvas isn't served at app.moleculesai.app — it backs the tenant subdomains (acme.moleculesai.app etc.). The real /waitlist lives in the separate molecule-app repo, which is what the CP auth callback redirects to. molecule-app#12 has the real page + contact form wiring to /cp/waitlist/request. This canvas copy was never reachable and would only diverge. 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 deletions(-) delete mode 100644 canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx delete 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 deleted file mode 100644 index a0279708..00000000 --- a/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -// @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 deleted file mode 100644 index 94f95828..00000000 --- a/canvas/src/app/waitlist/page.tsx +++ /dev/null @@ -1,189 +0,0 @@ -"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} - /> -
- -
- -