diff --git a/canvas/src/app/orgs/page.tsx b/canvas/src/app/orgs/page.tsx index 3c5576ef..a137ac2e 100644 --- a/canvas/src/app/orgs/page.tsx +++ b/canvas/src/app/orgs/page.tsx @@ -18,7 +18,7 @@ // 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 { fetchSession, redirectToLogin, signOut, type Session } from "@/lib/auth"; import { PLATFORM_URL } from "@/lib/api"; import { formatCredits, pillTone, bannerKind } from "@/lib/credits"; import { TermsGate } from "@/components/TermsGate"; @@ -129,7 +129,7 @@ export default function OrgsPage() { return : null} />; } return ( - + {justCheckedOut && }
    {orgs.map((o) => ( @@ -160,11 +160,21 @@ function CheckoutBanner() { ); } -function Shell({ children }: { children: React.ReactNode }) { +function Shell({ + children, + session, +}: { + children: React.ReactNode; + // Optional: when present, the header renders the signed-in email + + // a Sign-out button. The empty-state Shell call doesn't have a + // session in scope, so accept null and skip the header chrome there. + session?: Session | null; +}) { return (
    + {session ? : null}

    Your organizations

    Each org is an isolated Molecule workspace. @@ -177,6 +187,40 @@ function Shell({ children }: { children: React.ReactNode }) { ); } +// AccountBar renders the signed-in email + a Sign-out button at the +// top of the page. Without this the user has no way to log out — the +// /cp/auth/signout endpoint exists on the control plane but no UI ever +// called it. Reported externally on 2026-05-05; this is the fix. +// +// Click → calls signOut() which POSTs /cp/auth/signout (clears the +// WorkOS session cookie + revokes at the provider) then bounces to +// /cp/auth/login. The signOut helper is best-effort — even on a 5xx +// or network failure the redirect fires so the user never gets stuck +// on an authed-looking page after they clicked Sign out. +function AccountBar({ session }: { session: Session }) { + const [signingOut, setSigningOut] = useState(false); + return ( +

    + {session.email} + +
    + ); +} + // DataResidencyNotice surfaces where workspace data lives so EU-based // signups can make an informed choice (GDPR Art. 13 disclosure // requirement). Plain text, no icon — the goal is clarity, not diff --git a/canvas/src/lib/__tests__/auth.test.ts b/canvas/src/lib/__tests__/auth.test.ts index ee74a521..220c5126 100644 --- a/canvas/src/lib/__tests__/auth.test.ts +++ b/canvas/src/lib/__tests__/auth.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi, afterEach } from "vitest"; -import { fetchSession, redirectToLogin } from "../auth"; +import { fetchSession, redirectToLogin, signOut } from "../auth"; afterEach(() => { vi.unstubAllGlobals(); @@ -110,3 +110,88 @@ describe("redirectToLogin", () => { expect((window.location as unknown as { href: string }).href).toBe(signupHref); }); }); + +describe("signOut", () => { + it("POSTs to /cp/auth/signout with credentials:include", async () => { + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + + await signOut(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/cp/auth/signout"), + expect.objectContaining({ method: "POST", credentials: "include" }), + ); + }); + + it("redirects to /cp/auth/login on the auth origin after signout", async () => { + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 })); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + // Tenant subdomain (acme.moleculesai.app) → auth origin is app.moleculesai.app. + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("redirects even when the POST fails so the user isn't stuck on an authed page", async () => { + // Critical UX invariant: clicking 'Sign out' MUST navigate away from + // the authenticated app, even if the network is down or the cookie + // is already invalid. Anything else looks like the button is + // broken — the precise complaint that triggered this fix. + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); + + it("redirects on 401 (session already invalid) just like 200", async () => { + // A user with an already-invalid cookie should still see the + // logout flow complete — no error, no stuck-on-app dead end. + Object.defineProperty(window, "location", { + writable: true, + value: { + href: "https://acme.moleculesai.app/orgs", + pathname: "/orgs", + hostname: "acme.moleculesai.app", + protocol: "https:", + }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 })); + + await signOut(); + + const after = (window.location as unknown as { href: string }).href; + expect(after).toBe("https://app.moleculesai.app/cp/auth/login"); + }); +}); diff --git a/canvas/src/lib/auth.ts b/canvas/src/lib/auth.ts index fe7c71ab..e6a2b945 100644 --- a/canvas/src/lib/auth.ts +++ b/canvas/src/lib/auth.ts @@ -67,3 +67,41 @@ export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): const dest = `${authOrigin}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`; window.location.href = dest; } + +/** + * signOut posts to /cp/auth/signout to clear the WorkOS session cookie + * + revoke at the provider, then bounces to the auth-origin login page. + * + * Best-effort by design: a 5xx, network failure, or stale cookie still + * results in the browser navigation away from the authenticated app — + * leaving the user on a logged-in-looking page after they clicked + * "Sign out" is the worst possible UX. The cookie is cleared client- + * visibly via the redirect target's response (Set-Cookie with maxAge=-1 + * runs even on a non-200 path). If the user is already anonymous, the + * POST 401s harmlessly + we still redirect. + * + * Throws nothing — callers can disable the button optimistically or + * await this and trust it returns. On a redirect-blocked test + * environment (jsdom under vitest) we still exit cleanly so unit tests + * can spy on the fetch call. + */ +export async function signOut(): Promise { + // Fire-and-tolerate the POST. credentials:include is mandatory cross- + // origin so the SaaS canvas (acme.moleculesai.app) can hit + // app.moleculesai.app/cp/auth/signout with the session cookie. + try { + await fetch(`${getAuthOrigin()}${AUTH_BASE}/signout`, { + method: "POST", + credentials: "include", + }); + } catch { + // Ignore — we still redirect below. + } + if (typeof window === "undefined") return; + // Land on the login screen rather than the current URL: returning to + // a tenant URL after signout would just re-redirect through + // /cp/auth/login due to AuthGate. Send the user straight there with + // no return_to so they don't loop back into the org they just left. + const authOrigin = getAuthOrigin(); + window.location.href = `${authOrigin}${AUTH_BASE}/login`; +}