From f29500c138bdf97c724268a2bfdebf4104dfdad4 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 9 Jul 2026 12:44:19 +0100 Subject: [PATCH] Disable Portal UI for guests (#6936) # Description of Changes Disallow SaaS guests from accessing the portal. One day we might want to make this better so they can go there but then have to sign up before doing anything useful, but this is the easiest way to disallow it for now. --- .../auth/PortalAuthBoundary.test.tsx | 24 +++++++++++++++++-- .../portal-saas/auth/PortalAuthBoundary.tsx | 24 +++++++++++-------- 2 files changed, 36 insertions(+), 12 deletions(-) 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 (