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:
parent
393ecc74e3
commit
b29ffb9546
278
canvas/src/app/orgs/page.tsx
Normal file
278
canvas/src/app/orgs/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user