diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx new file mode 100644 index 00000000..955d3f46 --- /dev/null +++ b/canvas/src/app/orgs/page.tsx @@ -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("loading"); + const [orgs, setOrgs] = useState(null); + const [error, setError] = useState(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

Loading…

; + } + if (error) { + return ( + +

Error: {error}

+ +
+ ); + } + if (!orgs || orgs.length === 0) { + return ; + } + return ( + +
    + {orgs.map((o) => ( + + ))} +
+
+ { + // Refresh the list so the new org appears + its CTA fires. + window.location.reload(); + void slug; + }} + /> +
+
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+

Your organizations

+

+ Each org is an isolated Molecule workspace. +

+
{children}
+
+
+ ); +} + +function OrgRow({ org }: { org: Org }) { + return ( +
  • +
    +
    +
    {org.name}
    +
    + {org.slug} · · {org.plan || "free"} +
    +
    + +
    +
  • + ); +} + +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 {label}; +} + +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 ( + + Open + + ); + } + if (org.status === "awaiting_payment") { + return ( + + Complete payment + + ); + } + if (org.status === "failed") { + return ( + + Contact support + + ); + } + // provisioning / unknown — non-interactive + return {org.status}…; +} + +function EmptyState() { + return ( + +

    + You don't have any organizations yet. Create one to get started — your + workspace spins up automatically once billing is set up. +

    +
    + { + window.location.reload(); + }} + /> +
    +
    + ); +} + +function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) { + const [slug, setSlug] = useState(""); + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [err, setErr] = useState(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 ( +
    + + + {err &&

    {err}

    } + +
    + ); +}