forked from molecule-ai/molecule-core
chore(canvas): remove dead /waitlist page (lives in molecule-app)
#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) <noreply@anthropic.com>
This commit is contained in:
parent
59dd873f26
commit
f59473f1fd
@ -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(<WaitlistPage />);
|
||||
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=<urlencoded>.
|
||||
// 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(<WaitlistPage />);
|
||||
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(<WaitlistPage />);
|
||||
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(<WaitlistPage />);
|
||||
// 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(<WaitlistPage />);
|
||||
|
||||
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(<WaitlistPage />);
|
||||
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(<WaitlistPage />);
|
||||
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(<WaitlistPage />);
|
||||
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(<WaitlistPage />);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<SubmitState>("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 (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-xl px-6 py-20">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-white md:text-5xl">
|
||||
You’re on the waitlist
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-zinc-300">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{state === "success" && (
|
||||
<div
|
||||
role="status"
|
||||
className="mt-8 rounded-lg border border-emerald-800 bg-emerald-950/50 p-4 text-emerald-200"
|
||||
>
|
||||
Thanks — your request is in. We’ll email{" "}
|
||||
<span className="font-mono">{email}</span> when access opens up.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "dedup" && (
|
||||
<div
|
||||
role="status"
|
||||
className="mt-8 rounded-lg border border-sky-800 bg-sky-950/50 p-4 text-sky-200"
|
||||
>
|
||||
We already have your request on file for{" "}
|
||||
<span className="font-mono">{email}</span>. No need to resubmit —
|
||||
we’ll be in touch.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state !== "success" && state !== "dedup" && (
|
||||
<form className="mt-8 space-y-5" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-zinc-200">
|
||||
Email <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-zinc-200">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="use_case" className="block text-sm font-medium text-zinc-200">
|
||||
What would you build with this?
|
||||
</label>
|
||||
<textarea
|
||||
id="use_case"
|
||||
rows={4}
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(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="Research assistant, customer support automation, internal ops agent…"
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Helps us prioritize who to let in first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{state === "error" && errorMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-rose-800 bg-rose-950/50 px-3 py-2 text-sm text-rose-200"
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-5 py-2.5 font-medium text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-blue-900 disabled:text-blue-300"
|
||||
>
|
||||
{state === "submitting" ? "Submitting…" : "Request access"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-12 text-sm text-zinc-500">
|
||||
Questions? Email{" "}
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
support@moleculesai.app
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user