diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx index 271fbbfa50..367a3c59f7 100644 --- a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx @@ -4,9 +4,14 @@ import { render, screen } from "@testing-library/react"; import { allowConsole } from "@app/tests/failOnConsole"; // Controllable auth state for the mocked provider. -const authState: { session: unknown; loading: boolean } = { +const authState: { + session: unknown; + loading: boolean; + isAnonymous: boolean; +} = { session: null, loading: false, + isAnonymous: false, }; vi.mock("@app/auth", () => ({ @@ -23,9 +28,10 @@ describe("PortalAuthBoundary — SaaS", () => { beforeEach(() => { authState.session = null; authState.loading = false; + authState.isAnonymous = false; }); - it("renders the portal when a Supabase session is present (no login)", () => { + it("renders the portal when a real (non-guest) Supabase session is present", () => { authState.session = { user: { id: "u1" }, access_token: "tok" }; render( @@ -35,6 +41,20 @@ describe("PortalAuthBoundary — SaaS", () => { expect(screen.getByTestId("portal")).toBeInTheDocument(); }); + it("gates (does not render the portal) for an anonymous guest session", () => { + authState.session = { user: { id: "guest" }, access_token: "tok" }; + authState.isAnonymous = true; + // The gate bounces a guest to the editor; jsdom doesn't implement + // navigation, so absorb that incidental warning. + allowConsole.error(/not implemented|navigation/i); + render( + +
PORTAL
+
, + ); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); + it("gates (does not render the portal) when there is no session", () => { authState.session = null; // The gate bounces to /login; jsdom doesn't implement navigation, so absorb diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx index b97d57de4e..851c011ae0 100644 --- a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx @@ -3,6 +3,7 @@ import { AuthProvider } from "@app/auth"; import { useAuth } from "@app/auth/context"; import { Spinner } from "@app/ui"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { EDITOR_URL } from "@portal/auth/editorUrl"; function FullScreen({ children }: { children: ReactNode }) { return ( @@ -20,19 +21,22 @@ function FullScreen({ children }: { children: ReactNode }) { } /** - * SaaS gate: viewing your own usage is not admin-gated, so require only a session - * (not portalAccess). No session → bounce to the editor's Supabase login, which - * returns here signed in. This is deliberately laxer than the self-hosted - * RequirePortalAccess admin gate. + * SaaS gate: viewing your own usage is not admin-gated, so any real (signed-in, + * non-guest) account may enter - deliberately laxer than the self-hosted + * RequirePortalAccess admin gate. But an anonymous guest session has no account + * to view or manage, so it is not eligible: bounce it to the editor (where a + * guest can sign up), mirroring the self-hosted forbidden path. No session at + * all -> the editor's Supabase login, which returns here signed in. */ function SaasPortalGate({ children }: { children: ReactNode }) { - const { session, loading } = useAuth(); + const { session, loading, isAnonymous } = useAuth(); + const blocked = !loading && (!session || isAnonymous); useEffect(() => { - if (!loading && !session) { - window.location.href = "/login"; - } - }, [loading, session]); - if (loading || !session) { + if (!blocked) return; + // Guest (has a session but anonymous) -> editor; no session -> login. + window.location.href = session ? EDITOR_URL : "/login"; + }, [blocked, session]); + if (loading || blocked) { return (