diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 4d4aadc8f8..8547e9092a 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -61,6 +61,11 @@ const config: StorybookConfig = {
config.define = {
...(config.define ?? {}),
"import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"),
+ // Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and
+ // never replaces the mock SaaS client stubbed in preview.tsx.
+ "import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""),
+ "import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY":
+ JSON.stringify(""),
};
return config;
},
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index bab413a0e8..76463001fe 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -29,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers);
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
// method, wallet) clear the session check and reach the MSW handlers instead of
-// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally
-// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only
-// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via
-// .storybook/main.ts's viteFinal define, not a frontend/.env file.
+// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
+// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this
+// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured —
+// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file.
const saasStub = configureSupabase({
url: "http://saas.mock",
key: "storybook-anon-key",
diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary
index ed5498ba3a..0ee7e66bc9 100644
--- a/frontend/editor/.env.proprietary
+++ b/frontend/editor/.env.proprietary
@@ -18,13 +18,6 @@ VITE_EDITOR_URL=/
# backend.
VITE_PORTAL_MOCKS=
-# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
-# linking (both values are public). Set per deploy; absent -> the account-link
-# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
-# project the local backend links against.
-VITE_SAAS_SUPABASE_URL=
-VITE_SAAS_SUPABASE_ANON_KEY=
-
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
diff --git a/frontend/editor/src/portal-saas/PortalProviders.tsx b/frontend/editor/src/portal-saas/PortalProviders.tsx
new file mode 100644
index 0000000000..161b942582
--- /dev/null
+++ b/frontend/editor/src/portal-saas/PortalProviders.tsx
@@ -0,0 +1,20 @@
+import { TierProvider } from "@portal/contexts/TierContext";
+import { UIProvider } from "@portal/contexts/UIContext";
+import { PortalChrome } from "@portal/components/PortalChrome";
+
+/**
+ * SaaS provider stack. There is no account-link layer: the signed-in account IS
+ * the SaaS account (auth is handled upstream by PortalAuthBoundary) and the tier
+ * comes from the wallet (see portal-saas/contexts/usePlanTier). Dropping
+ * LinkProvider / AccountLinkProvider / the login modal here keeps the link
+ * machinery out of the SaaS bundle entirely.
+ */
+export function PortalProviders() {
+ return (
+
Never null — same-origin is always a valid base — so {@code apiClient.saas}
+ * never enters the self-hosted "SaaS not configured" state.
+ */
+export function saasApiBase(): string {
+ const raw = import.meta.env.VITE_API_BASE_URL ?? "/";
+ return raw.replace(/\/+$/, "");
+}
diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx
new file mode 100644
index 0000000000..271fbbfa50
--- /dev/null
+++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx
@@ -0,0 +1,60 @@
+import { type ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+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 } = {
+ session: null,
+ loading: false,
+};
+
+vi.mock("@app/auth", () => ({
+ // Passthrough — we drive gating via the mocked useAuth below.
+ AuthProvider: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("@app/auth/context", () => ({ useAuth: () => authState }));
+vi.mock("@app/ui", () => ({ Spinner: () => null }));
+vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
+
+import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
+
+describe("PortalAuthBoundary — SaaS", () => {
+ beforeEach(() => {
+ authState.session = null;
+ authState.loading = false;
+ });
+
+ it("renders the portal when a Supabase session is present (no login)", () => {
+ authState.session = { user: { id: "u1" }, access_token: "tok" };
+ render(
+
Self-hosted (this base): the SaaS cloud is a separate backend from + * this instance's local one, configured via {@code VITE_SAAS_API_URL}. Returns + * {@code null} when unset so {@code apiClient.saas} can surface a clear + * "configure" state. A set value has any trailing slash trimmed. + * + *
The SaaS build shadows this to reuse the editor's single backend + * ({@code VITE_API_BASE_URL}) — in SaaS everything is the SaaS backend, so there + * is no separate SaaS URL. + */ +export function saasApiBase(): string | null { + const raw = import.meta.env.VITE_SAAS_API_URL; + if (!raw) return null; + return raw.replace(/\/+$/, ""); +} diff --git a/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx new file mode 100644 index 0000000000..df24718eda --- /dev/null +++ b/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx @@ -0,0 +1,19 @@ +import { type ReactNode } from "react"; +import { AuthProvider } from "@app/auth"; +import { AuthGate } from "@portal/components/AuthGate"; + +/** + * Portal auth wiring — the seam the SaaS build overrides. + * + *
Self-hosted (this base): the portal is its own standalone app, so it owns a
+ * Spring {@link AuthProvider} and a Spring login gate ({@link AuthGate}). The SaaS
+ * build shadows this file to authenticate against the SaaS Supabase project (the
+ * same session the editor uses) with no Spring login and no account-link step.
+ */
+export function PortalAuthBoundary({ children }: { children: ReactNode }) {
+ return (
+ Self-hosted (this base): billing only makes sense once the instance has
+ * linked its SaaS account, so gate on link state — unlinked shows the link prompt;
+ * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the
+ * link/tier dimension: the wallet's subscription status refines the plan/tier
+ * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps
+ * the "link" concept entirely out of the Usage page. The SaaS build shadows this
+ * with a passthrough — there is no linking there.
+ */
+export function PortalBillingGate() {
+ const { isLinked } = useLink();
+ const applyLinkFacts = useApplyLinkFacts();
+ const { openLinkModal } = useUI();
+
+ const onWalletLoaded = useCallback(
+ (w: Wallet) => applyLinkFacts(true, w.status === "subscribed"),
+ [applyLinkFacts],
+ );
+ const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]);
+
+ if (!isLinked) return VITE_SAAS_SUPABASE_URL{" "}
+ VITE_SUPABASE_URL{" "}
{t(
"portal.accountLink.card.loginNotConfigured.after",
"to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.",
diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
index 3f8da872c8..e46504d309 100644
--- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
+++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
@@ -96,9 +96,9 @@ export function LinkAccountModal({
)}
>
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
- VITE_SAAS_SUPABASE_URL{" "}
+ VITE_SUPABASE_URL{" "}
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
- VITE_SAAS_SUPABASE_ANON_KEY{" "}
+ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "}
{t(
"portal.accountLink.modal.loginNotConfigured.after",
"to enable in-app linking against the hosted Stirling account.",
diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx
new file mode 100644
index 0000000000..f15d8ade51
--- /dev/null
+++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx
@@ -0,0 +1,39 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+const linkState = { isLinked: false };
+vi.mock("@portal/contexts/LinkContext", () => ({
+ useLink: () => linkState,
+ useApplyLinkFacts: () => vi.fn(),
+}));
+vi.mock("@portal/contexts/UIContext", () => ({
+ useUI: () => ({ openLinkModal: vi.fn() }),
+}));
+vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({
+ LinkAccountPrompt: () => ,
+}));
+vi.mock("@portal/views/Usage", () => ({
+ Usage: () => ,
+}));
+
+import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
+
+describe("PortalBillingGate — self-hosted", () => {
+ beforeEach(() => {
+ linkState.isLinked = false;
+ });
+
+ it("shows the link prompt when unlinked (billing gated on link)", () => {
+ linkState.isLinked = false;
+ render(