feat(canvas): /orgs landing page for post-signup users

CP's Callback handler redirects every new WorkOS session to
APP_URL/orgs, but canvas had no such route — new users hit the canvas
Home component, which tries to call /workspaces on a tenant that
doesn't exist yet, and saw a confusing error. This PR plugs that gap
with a dedicated landing page that:

- Bounces anonymous visitors back to /cp/auth/login
- Zero-org users see a slug-picker (POST /cp/orgs, refresh)
- For each existing org, shows status + CTA:
  * awaiting_payment → amber "Complete payment" → /pricing?org=…
  * running          → emerald "Open" → https://<slug>.moleculesai.app
  * failed           → "Contact support" → mailto
  * provisioning     → read-only "provisioning…"
- Surfaces errors inline with a Retry button

Deliberately server-light: one GET /cp/orgs, no WebSocket, no canvas
store hydration. Goal is to move the user from signup to either
Stripe Checkout or their tenant URL with one click each.

Closes the last UX gap between the BILLING_REQUIRED gate landing on
the CP and real users being able to complete a signup today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-19 04:13:54 -07:00
parent 393ecc74e3
commit b29ffb9546

View File

@ -0,0 +1,278 @@
"use client";
// /orgs — the post-signup landing page.
//
// The control plane's Callback handler (authorized via WorkOS) redirects
// every new session to APP_URL/orgs after login/signup succeeds. Before
// this route existed that redirect 404'd and new users were stranded.
// Now:
// - Signed-out browsers are bounced back to /cp/auth/login
// - Zero-org users see a slug-picker → POST /cp/orgs → refresh
// - `awaiting_payment` orgs get a "Complete payment" CTA → /pricing
// - `running` orgs show a link to the tenant URL
// - `provisioning` / `failed` surface the state so the user knows
// why their tenant isn't available yet
//
// Everything here is intentionally server-light: one GET /cp/orgs,
// zero WebSocket, no canvas store hydration — the whole point is a
// quick bounce between signup and either Checkout or the tenant UI.
import { useEffect, useState } from "react";
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
import { PLATFORM_URL } from "@/lib/api";
type OrgStatus = "awaiting_payment" | "provisioning" | "running" | "failed" | string;
interface Org {
id: string;
slug: string;
name: string;
plan: string;
status: OrgStatus;
created_at: string;
updated_at: string;
}
export default function OrgsPage() {
const [session, setSession] = useState<Session | null | "loading">("loading");
const [orgs, setOrgs] = useState<Org[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const sess = await fetchSession();
if (cancelled) return;
if (!sess) {
redirectToLogin();
return;
}
setSession(sess);
const res = await fetch(`${PLATFORM_URL}/cp/orgs`, {
credentials: "include",
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
throw new Error(`GET /cp/orgs: ${res.status}`);
}
const body = (await res.json()) as { orgs?: Org[] } | Org[];
const list = Array.isArray(body) ? body : body.orgs ?? [];
if (!cancelled) setOrgs(list);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
}
})();
return () => {
cancelled = true;
};
}, []);
if (session === "loading" || (orgs === null && error === null)) {
return <Shell><p className="text-zinc-400">Loading</p></Shell>;
}
if (error) {
return (
<Shell>
<p className="text-red-400">Error: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
>
Retry
</button>
</Shell>
);
}
if (!orgs || orgs.length === 0) {
return <EmptyState />;
}
return (
<Shell>
<ul className="space-y-3">
{orgs.map((o) => (
<OrgRow key={o.id} org={o} />
))}
</ul>
<div className="mt-8 border-t border-zinc-800 pt-6">
<CreateOrgForm
onCreated={(slug) => {
// Refresh the list so the new org appears + its CTA fires.
window.location.reload();
void slug;
}}
/>
</div>
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
<p className="mt-2 text-zinc-400">
Each org is an isolated Molecule workspace.
</p>
<div className="mt-8">{children}</div>
</div>
</main>
);
}
function OrgRow({ org }: { org: Org }) {
return (
<li className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-white">{org.name}</div>
<div className="text-sm text-zinc-400">
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
</div>
</div>
<OrgCTA org={org} />
</div>
</li>
);
}
function StatusLabel({ status }: { status: OrgStatus }) {
const cls =
status === "running"
? "text-emerald-400"
: status === "awaiting_payment"
? "text-amber-400"
: status === "failed"
? "text-red-400"
: "text-sky-400";
const label =
status === "awaiting_payment"
? "awaiting payment"
: status;
return <span className={cls}>{label}</span>;
}
function OrgCTA({ org }: { org: Org }) {
if (org.status === "running") {
const host = typeof window !== "undefined" ? window.location.hostname : "moleculesai.app";
const appDomain = host.endsWith(".moleculesai.app")
? host.split(".").slice(-2).join(".")
: "moleculesai.app";
const href = `https://${org.slug}.${appDomain}`;
return (
<a
href={href}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Open
</a>
);
}
if (org.status === "awaiting_payment") {
return (
<a
href={`/pricing?org=${encodeURIComponent(org.slug)}`}
className="rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500"
>
Complete payment
</a>
);
}
if (org.status === "failed") {
return (
<a
href="mailto:support@moleculesai.app"
className="rounded bg-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-600"
>
Contact support
</a>
);
}
// provisioning / unknown — non-interactive
return <span className="text-sm text-zinc-500">{org.status}</span>;
}
function EmptyState() {
return (
<Shell>
<p className="text-zinc-300">
You don&apos;t have any organizations yet. Create one to get started your
workspace spins up automatically once billing is set up.
</p>
<div className="mt-6">
<CreateOrgForm
onCreated={() => {
window.location.reload();
}}
/>
</div>
</Shell>
);
}
function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setErr(null);
try {
const res = await fetch(`${PLATFORM_URL}/cp/orgs`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug, name }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`${res.status}: ${body}`);
}
onCreated(slug);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
setSubmitting(false);
}
}
return (
<form onSubmit={submit} className="space-y-3">
<label className="block">
<span className="text-sm text-zinc-300">Slug (URL)</span>
<input
value={slug}
onChange={(e) => setSlug(e.target.value.toLowerCase())}
pattern="^[a-z][a-z0-9-]{2,31}$"
placeholder="acme"
required
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
<label className="block">
<span className="text-sm text-zinc-300">Display name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Corp"
required
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
/>
</label>
{err && <p className="text-sm text-red-400">{err}</p>}
<button
type="submit"
disabled={submitting}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
>
{submitting ? "Creating…" : "Create organization"}
</button>
</form>
);
}