From 8df49ac053dbc464b00d2dbd64774f6a69628f59 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Wed, 8 Jul 2026 13:07:54 +0100
Subject: [PATCH] feat(portal): build portal for SaaS and self-hosted via
file-override layer (#6900)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What
Ground-work so the admin portal can build for the **SaaS flavor**
alongside self-hosted, using the editor's existing **build-time
file-override** mechanism — no runtime flavor flags. This PR
demonstrates SaaS end-to-end (single-login + Usage page loading via the
inherited Supabase session) without changing self-hosted behaviour.
This is intentionally scoped as foundations, not the whole feature.
## How
**Build hook**
- `tsconfig.saas.vite.json`: `@portal/*` now cascades
(`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the
explicit base path.
- New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only
overrides, so `@app/*` resolves only editor layers and `@portal/*` only
portal layers. Self-hosted builds never import it (tree-shaken).
**Seams live in the api-client + composition layers — never in page
components**
- `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`;
SaaS reuses the single `VITE_API_BASE_URL` backend).
- `portalSaasSession` — flavor-agnostic token from the shared Supabase
client.
- `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` +
`AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits
the SaaS session, so no second login).
**Link concept pulled out of the Usage page (one clean cut)**
- `Usage` is now a link-free wallet renderer with generic
`onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and
has zero flavor awareness.
- `PortalBillingGate` is the single flavor seam: self-hosted gates on
link (prompt when unlinked; wires the callbacks onto link/tier +
re-auth), SaaS is a passthrough that renders `Usage` directly.
- Keeps the flavor switch out of the page entirely (no per-flavor code
in `Usage`).
## Testing
Green locally and in CI (CI runs the umbrella `task
frontend:check:all`):
- `task frontend:typecheck:all` — clean across all 7 build variants
- `task frontend:test` — vitest suites pass (portal + saas cover this
change; 146 tests)
- `task frontend:build:saas` and `task frontend:build:proprietary` —
both green
- `task frontend:lint` and `task frontend:format:check` — clean
## Also in this PR (added after the initial foundations)
- **Tier from wallet + full link-layer excision on SaaS.** `TierContext`
no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted
from link state, SaaS from `wallet.status`), and the SaaS
`PortalProviders` drops `LinkProvider` / `AccountLinkProvider` /
`LinkModalHost` entirely — the link machinery is *absent* from the SaaS
bundle, not mounted-but-inert.
## Deliberately out of scope (follow-ups)
- SaaS-only read-only "connected servers" settings view.
- Shared wallet source so the SaaS tier badge and the Usage page don't
both fetch `/payg/wallet` (harmless double-fetch today).
---
frontend/.storybook/main.ts | 5 +
frontend/.storybook/preview.tsx | 8 +-
frontend/editor/.env.proprietary | 7 -
.../src/portal-saas/PortalProviders.tsx | 20 +++
.../src/portal-saas/api/saasApiBase.test.ts | 21 +++
.../editor/src/portal-saas/api/saasApiBase.ts | 13 ++
.../auth/PortalAuthBoundary.test.tsx | 60 ++++++++
.../portal-saas/auth/PortalAuthBoundary.tsx | 61 ++++++++
.../components/LinkAccountFooterItem.test.tsx | 10 ++
.../components/LinkAccountFooterItem.tsx | 7 +
.../billing/PortalBillingGate.test.tsx | 15 ++
.../components/billing/PortalBillingGate.tsx | 20 +++
.../settings/accountLinkSettings.test.ts | 8 ++
.../settings/accountLinkSettings.tsx | 9 ++
.../portal-saas/contexts/usePlanTier.test.tsx | 35 +++++
.../src/portal-saas/contexts/usePlanTier.ts | 14 ++
.../contexts/usePortalLinked.test.tsx | 16 +++
.../portal-saas/contexts/usePortalLinked.ts | 9 ++
frontend/editor/src/portal/PortalApp.tsx | 133 ++----------------
.../editor/src/portal/PortalProviders.tsx | 63 +++++++++
frontend/editor/src/portal/ViewRouter.tsx | 4 +-
frontend/editor/src/portal/api/http.ts | 32 ++---
frontend/editor/src/portal/api/saasApiBase.ts | 18 +++
.../src/portal/auth/PortalAuthBoundary.tsx | 19 +++
.../src/portal/auth/portalSaasSession.test.ts | 43 ++++++
.../src/portal/auth/portalSaasSession.ts | 19 +++
.../src/portal/auth/saasSupabase.test.ts | 45 ++++++
.../editor/src/portal/auth/saasSupabase.ts | 17 ++-
frontend/editor/src/portal/billing/stripe.ts | 2 +-
.../components/LinkAccountFooterItem.tsx | 27 ++++
.../src/portal/components/PortalChrome.tsx | 84 +++++++++++
.../src/portal/components/SettingsModal.tsx | 23 +--
.../editor/src/portal/components/Sidebar.tsx | 24 +---
.../account-link/LinkAccountCard.tsx | 2 +-
.../account-link/LinkAccountModal.tsx | 4 +-
.../billing/PortalBillingGate.test.tsx | 39 +++++
.../components/billing/PortalBillingGate.tsx | 32 +++++
.../procurement/ProcurementHome.tsx | 4 +-
.../settings/accountLinkSettings.tsx | 26 ++++
.../src/portal/contexts/TierContext.tsx | 31 ++--
.../src/portal/contexts/usePlanTier.test.tsx | 28 ++++
.../editor/src/portal/contexts/usePlanTier.ts | 24 ++++
.../portal/contexts/usePortalLinked.test.tsx | 28 ++++
.../src/portal/contexts/usePortalLinked.ts | 12 ++
frontend/editor/src/portal/tsconfig.json | 1 -
.../editor/src/portal/views/Usage.test.tsx | 67 +++++++++
frontend/editor/src/portal/views/Usage.tsx | 100 +++++++------
frontend/editor/src/portal/vite-env.d.ts | 8 +-
frontend/editor/src/saas/auth/supabase.ts | 18 +--
frontend/editor/src/saas/tsconfig.json | 12 +-
frontend/editor/tsconfig.portal.vite.json | 7 +-
frontend/editor/tsconfig.saas.vite.json | 3 +-
frontend/editor/vitest.config.ts | 8 +-
frontend/eslint.config.mjs | 3 +-
54 files changed, 1045 insertions(+), 303 deletions(-)
create mode 100644 frontend/editor/src/portal-saas/PortalProviders.tsx
create mode 100644 frontend/editor/src/portal-saas/api/saasApiBase.test.ts
create mode 100644 frontend/editor/src/portal-saas/api/saasApiBase.ts
create mode 100644 frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx
create mode 100644 frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx
create mode 100644 frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx
create mode 100644 frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx
create mode 100644 frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx
create mode 100644 frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx
create mode 100644 frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts
create mode 100644 frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx
create mode 100644 frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx
create mode 100644 frontend/editor/src/portal-saas/contexts/usePlanTier.ts
create mode 100644 frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx
create mode 100644 frontend/editor/src/portal-saas/contexts/usePortalLinked.ts
create mode 100644 frontend/editor/src/portal/PortalProviders.tsx
create mode 100644 frontend/editor/src/portal/api/saasApiBase.ts
create mode 100644 frontend/editor/src/portal/auth/PortalAuthBoundary.tsx
create mode 100644 frontend/editor/src/portal/auth/portalSaasSession.test.ts
create mode 100644 frontend/editor/src/portal/auth/portalSaasSession.ts
create mode 100644 frontend/editor/src/portal/auth/saasSupabase.test.ts
create mode 100644 frontend/editor/src/portal/components/LinkAccountFooterItem.tsx
create mode 100644 frontend/editor/src/portal/components/PortalChrome.tsx
create mode 100644 frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx
create mode 100644 frontend/editor/src/portal/components/billing/PortalBillingGate.tsx
create mode 100644 frontend/editor/src/portal/components/settings/accountLinkSettings.tsx
create mode 100644 frontend/editor/src/portal/contexts/usePlanTier.test.tsx
create mode 100644 frontend/editor/src/portal/contexts/usePlanTier.ts
create mode 100644 frontend/editor/src/portal/contexts/usePortalLinked.test.tsx
create mode 100644 frontend/editor/src/portal/contexts/usePortalLinked.ts
create mode 100644 frontend/editor/src/portal/views/Usage.test.tsx
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(