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 ( + + + + + + ); +} diff --git a/frontend/editor/src/portal-saas/api/saasApiBase.test.ts b/frontend/editor/src/portal-saas/api/saasApiBase.test.ts new file mode 100644 index 0000000000..6330ea4755 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/saasApiBase.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { saasApiBase } from "@portal/api/saasApiBase"; + +describe("saasApiBase — SaaS build (one backend, VITE_API_BASE_URL)", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("reuses the editor's single backend base", () => { + vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8080"); + expect(saasApiBase()).toBe("http://localhost:8080"); + }); + + it("trims a trailing slash", () => { + vi.stubEnv("VITE_API_BASE_URL", "https://app.stirling.com/"); + expect(saasApiBase()).toBe("https://app.stirling.com"); + }); + + it("maps same-origin '/' to '' — a valid (non-null) base, never 'unconfigured'", () => { + vi.stubEnv("VITE_API_BASE_URL", "/"); + expect(saasApiBase()).toBe(""); + }); +}); diff --git a/frontend/editor/src/portal-saas/api/saasApiBase.ts b/frontend/editor/src/portal-saas/api/saasApiBase.ts new file mode 100644 index 0000000000..e669dbf898 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/saasApiBase.ts @@ -0,0 +1,13 @@ +/** + * SaaS build: everything is the SaaS backend, so portal→SaaS reads target the + * SAME base URL the editor uses ({@code VITE_API_BASE_URL}) rather than a separate + * {@code VITE_SAAS_API_URL}. Default {@code "/"} → same-origin (the dev proxy / + * deployment forwards to the SaaS backend); the Supabase JWT is the one credential. + * + *

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( + +

PORTAL
+ , + ); + expect(screen.getByTestId("portal")).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 + // that incidental warning rather than fail the console guard. + allowConsole.error(/not implemented|navigation/i); + render( + +
PORTAL
+
, + ); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); + + it("gates while the session is still resolving", () => { + authState.loading = true; + render( + +
PORTAL
+
, + ); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx new file mode 100644 index 0000000000..b97d57de4e --- /dev/null +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx @@ -0,0 +1,61 @@ +import { useEffect, type ReactNode } from "react"; +import { AuthProvider } from "@app/auth"; +import { useAuth } from "@app/auth/context"; +import { Spinner } from "@app/ui"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +function FullScreen({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** + * 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. + */ +function SaasPortalGate({ children }: { children: ReactNode }) { + const { session, loading } = useAuth(); + useEffect(() => { + if (!loading && !session) { + window.location.href = "/login"; + } + }, [loading, session]); + if (loading || !session) { + return ( + + + + ); + } + return <>{children}; +} + +/** + * SaaS override of the portal auth boundary: authenticate against the SaaS Supabase + * project (inheriting the editor's session) instead of the self-hosted Spring login. + */ +export function PortalAuthBoundary({ children }: { children: ReactNode }) { + // Configure the shared Supabase client (SaaS project) synchronously here, before + // the AuthProvider below reads it — a useEffect would run too late for the first + // render, leaving the provider with a null client. Idempotent; the portal mounts + // outside the editor's AppProviders but against the SAME project, so a user already + // signed into the editor is picked up from the persisted session (no second login). + ensureSaasSupabase(); + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx new file mode 100644 index 0000000000..6f324b4cd4 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; + +describe("LinkAccountFooterItem (SaaS)", () => { + it("renders nothing — SaaS has no account to link", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx new file mode 100644 index 0000000000..be05e58bd6 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx @@ -0,0 +1,7 @@ +/** + * SaaS has no account-link concept — the signed-in account IS the SaaS account, + * so there is nothing to link and no footer CTA to show. + */ +export function LinkAccountFooterItem() { + return null; +} diff --git a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx new file mode 100644 index 0000000000..fa88c342c2 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("@portal/views/Usage", () => ({ + Usage: () =>
, +})); + +import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; + +describe("PortalBillingGate — SaaS", () => { + it("renders the Usage page directly, with no link concept", () => { + render(); + expect(screen.getByTestId("usage")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx new file mode 100644 index 0000000000..b02c485581 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx @@ -0,0 +1,20 @@ +import { Usage } from "@portal/views/Usage"; + +/** + * SaaS billing gate: there is no link concept — the signed-in account IS the SaaS + * account (auth is handled upstream by PortalAuthBoundary), so render the Usage + * page directly. No link state, no prompt. + * + * onReauth sends the user back to the editor's Supabase login if the session + * lapses mid-view: PortalAuthBoundary only re-gates on mount / session change, so + * without this a "Session expired" notice would dead-end until a manual reload. + */ +export function PortalBillingGate() { + return ( + { + window.location.href = "/login"; + }} + /> + ); +} diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts new file mode 100644 index 0000000000..5cc64727a1 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; + +describe("accountLinkSettings (SaaS)", () => { + it("is null — Settings has no account-link section on SaaS", () => { + expect(accountLinkSettings).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx new file mode 100644 index 0000000000..6fc0af735b --- /dev/null +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx @@ -0,0 +1,9 @@ +import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/settings/accountLinkSettings"; + +/** + * SaaS has no account-link concept — the signed-in account IS the SaaS account. + * Null drops the "Account link" nav item and its panel from Settings (the shared + * SettingsModal treats the seam as optional), so the link-only AccountLinkPanel + * is never imported into the SaaS bundle. + */ +export const accountLinkSettings: AccountLinkSettingsSeam | null = null; diff --git a/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx b/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx new file mode 100644 index 0000000000..d2a9666bff --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +const fetchWallet = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), +})); + +// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade. +import { usePlanTier } from "@portal/contexts/usePlanTier"; + +function Probe() { + return {usePlanTier()}; +} + +describe("usePlanTier (SaaS) — tier from wallet", () => { + beforeEach(() => { + fetchWallet.mockReset(); + }); + + it("subscribed wallet → pro", async () => { + fetchWallet.mockResolvedValue({ status: "subscribed" }); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId("tier").textContent).toBe("pro")); + }); + + it("free wallet → free (also the loading default)", async () => { + fetchWallet.mockResolvedValue({ status: "free" }); + const { getByTestId } = render(); + // Free before the fetch resolves and after — it never flips to pro. + expect(getByTestId("tier").textContent).toBe("free"); + await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1)); + expect(getByTestId("tier").textContent).toBe("free"); + }); +}); diff --git a/frontend/editor/src/portal-saas/contexts/usePlanTier.ts b/frontend/editor/src/portal-saas/contexts/usePlanTier.ts new file mode 100644 index 0000000000..6b877c3640 --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePlanTier.ts @@ -0,0 +1,14 @@ +import { useAsync } from "@portal/hooks/useAsync"; +import { fetchWallet } from "@portal/api/billing"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** + * The plan tier the portal runs on, SaaS flavor: the signed-in account IS the + * SaaS account, so the tier comes straight from the wallet — no link concept. + * `subscribed` → the metered Processor tier; anything else (including while the + * wallet is still loading) → free. Enterprise stays a mocks-only tier. + */ +export function usePlanTier(): Tier { + const { data: wallet } = useAsync(() => fetchWallet(), []); + return wallet?.status === "subscribed" ? "pro" : "free"; +} diff --git a/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx b/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx new file mode 100644 index 0000000000..1368b27a4b --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade. +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; + +function Probe() { + return {String(usePortalLinked())}; +} + +describe("usePortalLinked (SaaS)", () => { + it("is always true — no account-link step, no LinkProvider needed", () => { + // Renders with no LinkProvider in the tree: the SaaS override must not read it. + const { getByTestId } = render(); + expect(getByTestId("linked").textContent).toBe("true"); + }); +}); diff --git a/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts b/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts new file mode 100644 index 0000000000..4a5355f1d9 --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts @@ -0,0 +1,9 @@ +/** + * SaaS flavor: the signed-in account IS the SaaS account, so the portal is always + * authorized — there is no account-link step. Procurement/checkout treats this as + * "linked" and proceeds straight to the flow. No LinkContext dependency, so the + * link machinery stays out of the SaaS bundle. + */ +export function usePortalLinked(): boolean { + return true; +} diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 629a8ac57a..d14892363e 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,25 +1,8 @@ -import { useEffect, type ReactNode } from "react"; -import { useLocation } from "react-router-dom"; -import { AuthProvider } from "@app/auth"; -import { ErrorBoundary } from "@portal/components/ErrorBoundary"; +import { type ReactNode } from "react"; +import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; -import { TierProvider } from "@portal/contexts/TierContext"; -import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; -import { UIProvider, useUI } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; -import { AppShell } from "@portal/components/AppShell"; -import { AuthGate } from "@portal/components/AuthGate"; -import { AssistantButton } from "@portal/components/AssistantButton"; -import { AssistantPanel } from "@portal/components/AssistantPanel"; -import { SearchModal } from "@portal/components/SearchModal"; -import { SettingsModal } from "@portal/components/SettingsModal"; -import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; -import { - AccountLinkProvider, - useAccountLinkContext, -} from "@portal/contexts/AccountLinkContext"; -import { ViewRouter } from "@portal/ViewRouter"; +import { PortalProviders } from "@portal/PortalProviders"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -33,93 +16,15 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { return {children}; } -/** - * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch - * into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search - * palette. - */ -function GlobalShortcuts() { - const { toggleSearch, closeSearch } = useUI(); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; - if (isCmdK) { - e.preventDefault(); - toggleSearch(); - return; - } - if (e.key === "Escape") { - closeSearch(); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [toggleSearch, closeSearch]); - - return null; -} - -/** Bridges the Settings modal's open/close props to UIContext state. */ -function SettingsHost() { - const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); - return ( - - ); -} - -/** - * The one and only account-link login modal. Mounted at the app root (never - * nested in another overlay) and driven by UIContext, so any "Link account" CTA - * — sidebar, billing prompt, feature gate, Settings panel — opens this exact - * instance. Linking is finished by the shared {@link useAccountLinkContext} - * orchestration. - */ -function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); - const { markSaasSessionChanged } = useLink(); - const link = useAccountLinkContext(); - // "reauth" only refreshes the browser SaaS session for attended reads — the - // sign-in already applied it to the Supabase client, so we just signal a - // refetch. It must NOT call completeLink (that re-registers → duplicate row). - const onLinked = - linkModalMode === "reauth" - ? () => markSaasSessionChanged() - : (session: SupabaseLoginSession) => link.completeLink(session); - return ( - - ); -} - -/** - * The routed view, wrapped in an error boundary so a single view crashing can't - * white-screen the portal (the shell + nav stay alive). Keyed by route so - * navigating to another section clears any error from the previous one. - */ -function RoutedContent() { - const { pathname } = useLocation(); - return ( - - - - ); -} - /** * The portal, mounted as a route-set under /portal/* inside the editor app (via * the admin-route seam). It supplies its own providers and its own i18next * instance (the `portal` namespace), but NOT a router — the editor's * is the one and only router; the portal's routes are relative * to the /portal mount (see ViewRouter). + * + * The provider stack itself is a per-flavor seam (see {@link PortalProviders}): + * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { return ( @@ -127,29 +32,9 @@ export function PortalApp() { {/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
- - - {/* TierProvider sits INSIDE LinkProvider so it can derive the tier - from the real link/subscription state when MSW mocks are off. */} - - - - - - - - - - - - - - - - - - - + + +
diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx new file mode 100644 index 0000000000..f03092fde8 --- /dev/null +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -0,0 +1,63 @@ +import { TierProvider } from "@portal/contexts/TierContext"; +import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import { + AccountLinkProvider, + useAccountLinkContext, +} from "@portal/contexts/AccountLinkContext"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +/** + * The one and only account-link login modal. Mounted at the app root (never + * nested in another overlay) and driven by UIContext, so any "Link account" CTA + * — sidebar, billing prompt, feature gate, Settings panel — opens this exact + * instance. Linking is finished by the shared {@link useAccountLinkContext} + * orchestration. + */ +function LinkModalHost() { + const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { markSaasSessionChanged } = useLink(); + const link = useAccountLinkContext(); + // "reauth" only refreshes the browser SaaS session for attended reads — the + // sign-in already applied it to the Supabase client, so we just signal a + // refetch. It must NOT call completeLink (that re-registers → duplicate row). + const onLinked = + linkModalMode === "reauth" + ? () => markSaasSessionChanged() + : (session: SupabaseLoginSession) => link.completeLink(session); + return ( + + ); +} + +/** + * Self-hosted provider stack. The account-link layer (LinkProvider + + * AccountLinkProvider + the login modal) wraps the shared chrome; the tier is + * derived from the link/subscription state (see usePlanTier). TierProvider sits + * inside LinkProvider because the self-hosted usePlanTier reads useLink. + * + * The SaaS build shadows this file to drop the account-link layer entirely — the + * signed-in account IS the SaaS account, so there is nothing to link and the + * tier comes from the wallet. + */ +export function PortalProviders() { + return ( + + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 2b09a24507..62502e6c67 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -9,7 +9,7 @@ import { Policies } from "@portal/views/Policies"; import { Components } from "@portal/views/Components"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; -import { Usage } from "@portal/views/Usage"; +import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; import { DeveloperDocs } from "@portal/views/DeveloperDocs"; import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; @@ -39,7 +39,7 @@ export function ViewRouter() { path={rel(VIEW_PATHS.infrastructure)} element={} /> - } /> + } /> } /> } /> {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 9f7b28da03..2dfa914890 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -40,14 +40,17 @@ * admin and uses the Supabase JWT for SaaS reads. Don't add it here. */ import { clearStoredToken, getStoredToken } from "@app/auth"; -import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; -import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; +import { saasApiBase } from "@portal/api/saasApiBase"; -/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */ +/** + * SaaS base URL via the flavor seam: self-hosted reads VITE_SAAS_API_URL (a + * separate cloud backend); the SaaS build reuses the editor's single + * VITE_API_BASE_URL (in SaaS everything is the SaaS backend). {@code null} means + * not configured — a self-hosted-only state; the SaaS seam never returns null. + */ function saasBaseUrl(): string | null { - const raw = import.meta.env.VITE_SAAS_API_URL; - if (!raw) return null; - return raw.replace(/\/+$/, ""); + return saasApiBase(); } export interface HttpRequestOptions { @@ -169,21 +172,14 @@ async function localJson( // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── -async function getSaasAccessToken(): Promise { - ensureSaasSupabase(); - const supabase = getSupabaseClient(); - if (!supabase) return null; - const { data } = await supabase.auth.getSession(); - return data.session?.access_token ?? null; -} - async function saasJson( path: string, options: HttpRequestOptions = {}, ): Promise { const base = saasBaseUrl(); - if (!base) throw new SaasUnconfiguredError(); - const token = await getSaasAccessToken(); + // null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid. + if (base === null) throw new SaasUnconfiguredError(); + const token = await getPortalSaasToken(); if (!token) throw new SaasNotLinkedError(); const res = await fetch(`${base}${path}`, { method: options.method ?? "GET", @@ -213,7 +209,7 @@ export const apiClient = { /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ saas: { json: saasJson, - /** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */ - isConfigured: (): boolean => Boolean(saasBaseUrl()), + /** True when a SaaS base URL is resolvable. Doesn't check session liveness. */ + isConfigured: (): boolean => saasBaseUrl() !== null, }, } as const; diff --git a/frontend/editor/src/portal/api/saasApiBase.ts b/frontend/editor/src/portal/api/saasApiBase.ts new file mode 100644 index 0000000000..3514ac3c79 --- /dev/null +++ b/frontend/editor/src/portal/api/saasApiBase.ts @@ -0,0 +1,18 @@ +/** + * Base URL for attended portal→SaaS reads ({@code apiClient.saas}) — the seam the + * SaaS build overrides. + * + *

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 ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal/auth/portalSaasSession.test.ts b/frontend/editor/src/portal/auth/portalSaasSession.test.ts new file mode 100644 index 0000000000..1b12358b38 --- /dev/null +++ b/frontend/editor/src/portal/auth/portalSaasSession.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the vi.mock factories can reference it before the import runs. +const { state } = vi.hoisted(() => ({ + state: { client: null as { auth: { getSession: () => unknown } } | null }, +})); + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => state.client, +})); +vi.mock("@portal/auth/saasSupabase", () => ({ + ensureSaasSupabase: vi.fn(), +})); + +import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; + +describe("getPortalSaasToken — self-hosted (account-link login)", () => { + beforeEach(() => { + state.client = null; + }); + + it("returns null when the SaaS client isn't configured (not signed in)", async () => { + expect(await getPortalSaasToken()).toBeNull(); + }); + + it("returns the access token from the current session", async () => { + state.client = { + auth: { + getSession: async () => ({ + data: { session: { access_token: "tok-123" } }, + }), + }, + }; + expect(await getPortalSaasToken()).toBe("tok-123"); + }); + + it("returns null when there is a client but no active session", async () => { + state.client = { + auth: { getSession: async () => ({ data: { session: null } }) }, + }; + expect(await getPortalSaasToken()).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/auth/portalSaasSession.ts b/frontend/editor/src/portal/auth/portalSaasSession.ts new file mode 100644 index 0000000000..eb75cc7650 --- /dev/null +++ b/frontend/editor/src/portal/auth/portalSaasSession.ts @@ -0,0 +1,19 @@ +import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** + * SaaS access token for attended portal→SaaS reads. Reads the shared Supabase + * client, which {@link ensureSaasSupabase} configures against the one Stirling + * Supabase project (VITE_SUPABASE_*) both flavors talk to: self-hosted mints a + * SaaS JWT in-app for account-linking (alongside its own Spring session); SaaS is + * already signed into that same project, so the editor session is inherited. + * Flavor-agnostic — no per-flavor override needed. Returns null until a session + * exists. + */ +export async function getPortalSaasToken(): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} diff --git a/frontend/editor/src/portal/auth/saasSupabase.test.ts b/frontend/editor/src/portal/auth/saasSupabase.test.ts new file mode 100644 index 0000000000..b748cdd150 --- /dev/null +++ b/frontend/editor/src/portal/auth/saasSupabase.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock only the low-level client factory — NOT ensureSaasSupabase itself — so this +// exercises the real configurator and proves it wires the shared client from the +// one Stirling Supabase env (VITE_SUPABASE_*), shared by every flavor. +const configureSupabase = vi.fn(); +const getSupabaseClient = vi.fn(() => ({ __client: true })); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + configureSupabase, + getSupabaseClient, +})); + +describe("ensureSaasSupabase — configures the shared client from VITE_SUPABASE_*", () => { + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + configureSupabase.mockClear(); + getSupabaseClient.mockClear(); + }); + + it("configures from VITE_SUPABASE_* and returns the client", async () => { + vi.stubEnv("VITE_SUPABASE_URL", "https://proj.supabase.co"); + vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "anon-key"); + // Dynamic import so the module reads the stubbed env at load. + const { ensureSaasSupabase, isSaasSupabaseConfigured } = + await import("@portal/auth/saasSupabase"); + expect(isSaasSupabaseConfigured).toBe(true); + const client = ensureSaasSupabase(); + expect(configureSupabase).toHaveBeenCalledWith({ + url: "https://proj.supabase.co", + key: "anon-key", + }); + expect(client).not.toBeNull(); + }); + + it("stays unconfigured (client null) when the Supabase env is absent", async () => { + vi.stubEnv("VITE_SUPABASE_URL", ""); + vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", ""); + const { ensureSaasSupabase, isSaasSupabaseConfigured } = + await import("@portal/auth/saasSupabase"); + expect(isSaasSupabaseConfigured).toBe(false); + expect(ensureSaasSupabase()).toBeNull(); + expect(configureSupabase).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts index 813a2f7cad..ff3ad71b8b 100644 --- a/frontend/editor/src/portal/auth/saasSupabase.ts +++ b/frontend/editor/src/portal/auth/saasSupabase.ts @@ -4,18 +4,21 @@ import { } from "@app/auth/supabase/supabaseClient"; /** - * Configures the shared Supabase client against the hosted SaaS project so the - * portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a + * Configures the shared Supabase client against the Stirling Supabase project so + * the portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a * separate, transient SaaS auth — the portal's own session stays Spring (the * local instance admin); calls to the local backend still carry the Spring * bearer, and the SaaS JWT is passed only in the link request body. * - * Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public). - * Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to - * a "configure the SaaS Supabase URL" state. + * Config: VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY (both public) + * — the one Stirling Supabase project every flavor talks to (same vars the editor + * and proprietary billing client use; there is no separate SaaS project). SaaS + * needs no per-flavor override: the signed-in editor session is on this same + * project, so the client picks it up. Absent → {@link isSaasSupabaseConfigured} + * is false and the link UI degrades to a "configure Supabase" state. */ -const url = import.meta.env.VITE_SAAS_SUPABASE_URL; -const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY; +const url = import.meta.env.VITE_SUPABASE_URL; +const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const isSaasSupabaseConfigured = Boolean(url && key); diff --git a/frontend/editor/src/portal/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts index 91e0290d72..930ed3fef3 100644 --- a/frontend/editor/src/portal/billing/stripe.ts +++ b/frontend/editor/src/portal/billing/stripe.ts @@ -71,7 +71,7 @@ async function invoke( const supabase = getSupabaseClient(); if (!supabase) { throw new StripeFunctionError( - "SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.", + "SaaS Supabase not configured — set VITE_SUPABASE_URL.", "unconfigured", ); } diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx new file mode 100644 index 0000000000..fd3e9d96a0 --- /dev/null +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { NavItem } from "@app/ui"; +import { useUI } from "@portal/contexts/UIContext"; +import { useLink } from "@portal/contexts/LinkContext"; +import { LinkIcon } from "@portal/components/icons"; + +/** + * Sidebar-footer link-account CTA. Only visible when the org is unlinked — once + * linked, the linked-instances row + plan badge already communicate the state, + * so a permanent footer button would be noise. Click → opens the login modal + * directly. The SaaS build shadows this file with a no-op: the signed-in account + * IS the SaaS account, so there is nothing to link. + */ +export function LinkAccountFooterItem() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const { linkState } = useLink(); + if (linkState !== "unlinked") return null; + return ( + } + onClick={() => openLinkModal()} + /> + ); +} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx new file mode 100644 index 0000000000..9e2ed8d359 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -0,0 +1,84 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; +import { useUI } from "@portal/contexts/UIContext"; +import { AppShell } from "@portal/components/AppShell"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { AssistantPanel } from "@portal/components/AssistantPanel"; +import { SearchModal } from "@portal/components/SearchModal"; +import { SettingsModal } from "@portal/components/SettingsModal"; +import { ViewRouter } from "@portal/ViewRouter"; + +/** + * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into + * the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette. + */ +function GlobalShortcuts() { + const { toggleSearch, closeSearch } = useUI(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; + if (isCmdK) { + e.preventDefault(); + toggleSearch(); + return; + } + if (e.key === "Escape") { + closeSearch(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [toggleSearch, closeSearch]); + + return null; +} + +/** Bridges the Settings modal's open/close props to UIContext state. */ +function SettingsHost() { + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + return ( + + ); +} + +/** + * The routed view, wrapped in an error boundary so a single view crashing can't + * white-screen the portal (the shell + nav stay alive). Keyed by route so + * navigating to another section clears any error from the previous one. + */ +function RoutedContent() { + const { pathname } = useLocation(); + return ( + + + + ); +} + +/** + * The flavor-agnostic portal chrome: the shell (sidebar + header + routed view) + * plus the global overlays that every flavor shares. Requires only the Tier and + * UI contexts above it — both flavors provide those. Flavor-specific overlays + * (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not + * here. + */ +export function PortalChrome() { + return ( + <> + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx index 3073d243f0..eb2380a9a9 100644 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ b/frontend/editor/src/portal/components/SettingsModal.tsx @@ -31,9 +31,8 @@ import { PoliciesIcon, InfrastructureIcon, SparklesIcon, - LinkIcon, } from "@portal/components/icons"; -import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; import "@portal/components/SettingsModal.css"; type SettingsSection = @@ -150,11 +149,17 @@ export function SettingsModal({ { title: t("portal.settings.groups.admin"), items: [ - { - key: "account-link", - label: t("portal.settings.sections.account-link"), - icon: , - }, + // Account-link is a self-hosted-only section; the SaaS build shadows + // the seam to null, dropping the item entirely. + ...(accountLinkSettings + ? [ + { + key: accountLinkSettings.navKey, + label: t(accountLinkSettings.labelKey), + icon: accountLinkSettings.icon, + }, + ] + : []), { key: "authentication", label: t("portal.settings.sections.authentication"), @@ -337,7 +342,9 @@ export function SettingsModal({ /> )} - {section === "account-link" && } + {section === "account-link" && accountLinkSettings && ( + + )} ); diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 675e48561f..486611248b 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -4,7 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; -import { useLink } from "@portal/contexts/LinkContext"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL } from "@portal/auth/editorUrl"; @@ -20,7 +20,6 @@ import { ComponentsIcon, InfrastructureIcon, UsageIcon, - LinkIcon, DocsIcon, SettingsIcon, ChevronDownIcon, @@ -49,27 +48,6 @@ const GROUP_PLATFORM: NavEntry[] = [ { id: "docs", icon: }, ]; -/** - * Sidebar-footer link-account CTA. Only visible when the org is unlinked — once - * linked, the linked-instances row + plan badge already communicate the state, - * so a permanent footer button would be noise. Click → opens the login modal - * directly. - */ -function LinkAccountFooterItem() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - const { linkState } = useLink(); - if (linkState !== "unlinked") return null; - return ( - } - onClick={() => openLinkModal()} - /> - ); -} - function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx index fa79ed2c72..af43640905 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx @@ -49,7 +49,7 @@ export function LinkAccountCard({ link }: Props) { )} > {t("portal.accountLink.card.loginNotConfigured.before", "Set")}{" "} - 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(); + expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); + expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + }); + + it("renders the Usage page once linked", () => { + linkState.isLinked = true; + render(); + expect(screen.getByTestId("usage")).toBeInTheDocument(); + expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx new file mode 100644 index 0000000000..e448cfb93a --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { Usage } from "@portal/views/Usage"; +import type { Wallet } from "@portal/api/billing"; + +/** + * Billing access gate — the seam the SaaS build overrides. + * + *

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 ; + return ; +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx index 3c0498b71b..641bd55599 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; import { useUI } from "@portal/contexts/UIContext"; import { useView } from "@portal/contexts/ViewContext"; import { useAsync } from "@portal/hooks/useAsync"; @@ -46,7 +46,7 @@ import "@portal/views/Procurement.css"; */ export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { const { t } = useTranslation(); - const { isLinked } = useLink(); + const isLinked = usePortalLinked(); const { openLinkModal } = useUI(); const { setActiveView } = useView(); diff --git a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx new file mode 100644 index 0000000000..8e769672d7 --- /dev/null +++ b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx @@ -0,0 +1,26 @@ +import type { ComponentType, ReactNode } from "react"; +import { LinkIcon } from "@portal/components/icons"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; + +export interface AccountLinkSettingsSeam { + /** Section key in the Settings nav + body switch. */ + navKey: string; + /** i18n key for the nav label; resolved with `t()` at the call site. */ + labelKey: string; + icon: ReactNode; + /** The section body — the account-link panel. */ + Body: ComponentType; +} + +/** + * The admin "Account link" section of Settings (self-hosted only). The SaaS + * build shadows this file with `null`: the signed-in account IS the SaaS + * account, so there is no instance to link — the nav item and its panel both + * drop out, and nothing imports the link-only AccountLinkPanel. + */ +export const accountLinkSettings: AccountLinkSettingsSeam | null = { + navKey: "account-link", + labelKey: "portal.settings.sections.account-link", + icon: , + Body: AccountLinkPanel, +}; diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index 5f83159758..1b4742447b 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -7,7 +7,7 @@ import { type ReactNode, } from "react"; import { readMocksPreference } from "@portal/mocks/preference"; -import { useLink, type LinkState } from "@portal/contexts/LinkContext"; +import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; @@ -34,17 +34,6 @@ interface TierContextValue { const TierContext = createContext(null); -/** Maps the real link/subscription state onto the tier the rest of the portal reads. */ -function tierFromLinkState(linkState: LinkState): Tier { - switch (linkState) { - case "linked-subscribed": - return "pro"; - case "linked-free": - case "unlinked": - return "free"; - } -} - export function TierProvider({ children, initialTier = "pro", @@ -55,28 +44,30 @@ export function TierProvider({ // Mocks toggling reloads the page (see MocksToggle), so a single read at mount // is correct — the preference can't change without us remounting. const mocksOn = useMemo(() => readMocksPreference(), []); - const { linkState } = useLink(); + // Real derived tier. Its source is a per-flavor seam: self-hosted derives it + // from the link/subscription state, SaaS from the wallet (see usePlanTier). + const derivedTier = usePlanTier(); const [mockTier, setMockTier] = useState(initialTier); - // When mocks are off, mirror the real link state into the tier so any - // component still keyed on `tier` (sidebar plan badge, gated panels) stays - // consistent with the wallet. When mocks are on, the dropdown wins. + // When mocks are off, mirror the derived tier so any component keyed on `tier` + // (sidebar plan badge, gated panels) stays consistent. When mocks are on, the + // dropdown wins. useEffect(() => { if (!mocksOn) { - setMockTier(tierFromLinkState(linkState)); + setMockTier(derivedTier); } - }, [mocksOn, linkState]); + }, [mocksOn, derivedTier]); const value = useMemo( () => ({ - tier: mocksOn ? mockTier : tierFromLinkState(linkState), + tier: mocksOn ? mockTier : derivedTier, // Setter is a no-op when mocks are off — UI controls can disable themselves // via `isDerived`, but even if one slips through, it has no effect. setTier: mocksOn ? setMockTier : () => {}, isDerived: !mocksOn, }), - [mocksOn, mockTier, linkState], + [mocksOn, mockTier, derivedTier], ); return {children}; diff --git a/frontend/editor/src/portal/contexts/usePlanTier.test.tsx b/frontend/editor/src/portal/contexts/usePlanTier.test.tsx new file mode 100644 index 0000000000..b89022ff92 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePlanTier.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { usePlanTier } from "@portal/contexts/usePlanTier"; + +function Probe() { + return {usePlanTier()}; +} + +function renderTierFor(initialState: LinkState) { + return render( + + + , + ).getByTestId("tier").textContent; +} + +describe("usePlanTier (self-hosted) — tier from link state", () => { + it("unlinked → free", () => { + expect(renderTierFor("unlinked")).toBe("free"); + }); + it("linked-free → free", () => { + expect(renderTierFor("linked-free")).toBe("free"); + }); + it("linked-subscribed → pro", () => { + expect(renderTierFor("linked-subscribed")).toBe("pro"); + }); +}); diff --git a/frontend/editor/src/portal/contexts/usePlanTier.ts b/frontend/editor/src/portal/contexts/usePlanTier.ts new file mode 100644 index 0000000000..47598f12c3 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePlanTier.ts @@ -0,0 +1,24 @@ +import { useLink, type LinkState } from "@portal/contexts/LinkContext"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** Maps the self-hosted link/subscription state onto the portal tier. */ +function tierFromLinkState(linkState: LinkState): Tier { + switch (linkState) { + case "linked-subscribed": + return "pro"; + case "linked-free": + case "unlinked": + return "free"; + } +} + +/** + * The plan tier the portal runs on, self-hosted flavor: derived from whether the + * org has linked its SaaS account and carries a live subscription. The SaaS + * build shadows this file to derive the tier from the wallet instead — there is + * no link concept there (see portal-saas/contexts/usePlanTier). + */ +export function usePlanTier(): Tier { + const { linkState } = useLink(); + return tierFromLinkState(linkState); +} diff --git a/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx b/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx new file mode 100644 index 0000000000..37f7f799b3 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; + +function Probe() { + return {String(usePortalLinked())}; +} + +function renderLinkedFor(initialState: LinkState) { + return render( + + + , + ).getByTestId("linked").textContent; +} + +describe("usePortalLinked (self-hosted) — gated on the account link", () => { + it("unlinked → false", () => { + expect(renderLinkedFor("unlinked")).toBe("false"); + }); + it("linked-free → true", () => { + expect(renderLinkedFor("linked-free")).toBe("true"); + }); + it("linked-subscribed → true", () => { + expect(renderLinkedFor("linked-subscribed")).toBe("true"); + }); +}); diff --git a/frontend/editor/src/portal/contexts/usePortalLinked.ts b/frontend/editor/src/portal/contexts/usePortalLinked.ts new file mode 100644 index 0000000000..b7501ad128 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePortalLinked.ts @@ -0,0 +1,12 @@ +import { useLink } from "@portal/contexts/LinkContext"; + +/** + * Whether the portal is authorized against a SaaS billing account — the gate the + * procurement/checkout flow uses. Self-hosted flavor: true once the instance has + * linked its SaaS account. The SaaS build shadows this file to return true + * unconditionally: the signed-in account IS the SaaS account, so there is no link + * step and nothing to gate on. + */ +export function usePortalLinked(): boolean { + return useLink().isLinked; +} diff --git a/frontend/editor/src/portal/tsconfig.json b/frontend/editor/src/portal/tsconfig.json index 2eaab73a90..64fef64f42 100644 --- a/frontend/editor/src/portal/tsconfig.json +++ b/frontend/editor/src/portal/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "paths": { "@app/*": [ - "../../src/portal/*", "../../src/cloud/*", "../../src/proprietary/*", "../../src/core/*" diff --git a/frontend/editor/src/portal/views/Usage.test.tsx b/frontend/editor/src/portal/views/Usage.test.tsx new file mode 100644 index 0000000000..1df8b0525e --- /dev/null +++ b/frontend/editor/src/portal/views/Usage.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { ReactElement } from "react"; + +// Usage renders Mantine-backed @app/ui components (e.g. the "Manage Payment" +// Button in the subscribed header), which need a MantineProvider in the tree. +const renderUsage = (ui: ReactElement) => + render({ui}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, def?: string) => def ?? key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchWallet = vi.fn(); +const refreshWalletCache = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), + refreshWalletCache: () => refreshWalletCache(), +})); +vi.mock("@portal/api/link", () => ({ + fetchLocalUsage: () => Promise.resolve(null), + triggerLocalSync: () => Promise.resolve(), +})); +vi.mock("@portal/hooks/useStripePortal", () => ({ + useStripePortal: () => ({ opening: false, open: vi.fn(), error: null }), +})); +// Stub the plan views so the test doesn't depend on the full wallet shape. +vi.mock("@portal/components/billing/FreePlanView", () => ({ + FreePlanView: () => null, +})); +vi.mock("@portal/components/billing/SubscribedPlanView", () => ({ + SubscribedPlanView: () => null, +})); + +import { Usage } from "@portal/views/Usage"; + +describe("Usage — link-free wallet renderer", () => { + beforeEach(() => { + fetchWallet.mockReset(); + refreshWalletCache.mockReset(); + }); + + it("loads the wallet on mount and reports it via onWalletLoaded (no link gate)", async () => { + fetchWallet.mockResolvedValue({ status: "free" }); + const onWalletLoaded = vi.fn(); + + renderUsage(); + + // Renders immediately (no link prompt / login) and loads unconditionally. + expect(screen.getByText("Usage & billing")).toBeInTheDocument(); + await waitFor(() => + expect(onWalletLoaded).toHaveBeenCalledWith({ status: "free" }), + ); + }); + + it("works with no callbacks (SaaS passes none)", async () => { + fetchWallet.mockResolvedValue({ status: "subscribed" }); + + renderUsage(); + + await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/editor/src/portal/views/Usage.tsx b/frontend/editor/src/portal/views/Usage.tsx index 4a4493aa2c..776b210951 100644 --- a/frontend/editor/src/portal/views/Usage.tsx +++ b/frontend/editor/src/portal/views/Usage.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; import { fetchWallet, refreshWalletCache, @@ -14,7 +12,6 @@ import { type LocalUsage, } from "@portal/api/link"; import { useStripePortal } from "@portal/hooks/useStripePortal"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; import { FreePlanView } from "@portal/components/billing/FreePlanView"; import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; import { @@ -25,33 +22,44 @@ import { import "@portal/views/Usage.css"; import "@portal/components/billing/billing.css"; +export interface UsageProps { + /** + * Called with the wallet whenever it loads (initial fetch + post-checkout + * flip). A flavor-agnostic hook the composition uses for cross-cutting state — + * self-hosted maps it onto the link/tier dimension; SaaS ignores it. + */ + onWalletLoaded?: (wallet: Wallet) => void; + /** + * Invoked when the SaaS session has lapsed and the user chooses to re-sign-in. + * When omitted, the "session expired" notice shows without a sign-in action. + * Self-hosted wires this to its re-auth flow; SaaS leaves it unset (its session + * is owned by the app, so this path never triggers). + */ + onReauth?: () => void; +} + /** - * Billing & usage page. State-driven by the link/subscription dimension — - * NOT by the legacy {@code tier} prop: + * Billing & usage page — a flavor-agnostic wallet renderer. Whether it should be + * shown at all (self-hosted only renders it once the instance is linked) is + * decided upstream by the billing gate; this component always loads the wallet + * and dispatches on {@code wallet.status}: * - * unlinked → LinkAccountPrompt - * linked-free → FreePlanView (free meter + PAYG explainer) - * linked-subscribed → SubscribedPlanView (period meter, cap, members, - * invoices, Stripe portal) + * free → FreePlanView (free meter + PAYG explainer) + * subscribed → SubscribedPlanView (period meter, cap, members, invoices) * * Wallet comes from {@code GET /api/v1/payg/wallet} (apiClient.saas). After a - * subscription flip via Stripe checkout / cancel via the portal, the - * onWalletChange refresh re-reads and the view re-dispatches on the new - * status. + * checkout / cancel, the refresh re-reads and the view re-dispatches on status. */ -export function Usage() { +export function Usage({ onWalletLoaded, onReauth }: UsageProps = {}) { const { t } = useTranslation(); - const { isLinked, setLinkState, saasSessionNonce } = useLink(); - const { openLinkModal } = useUI(); const [wallet, setWallet] = useState(null); // Locally-accrued usage SaaS hasn't billed yet; added to the synced figure so // "current usage" reflects work since the last daily sync. Best-effort. const [localUsage, setLocalUsage] = useState(null); - const [loading, setLoading] = useState(isLinked); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // The instance is linked but the browser's SaaS session has lapsed — needs a - // re-sign-in, NOT a re-link. - const [needsReauth, setNeedsReauth] = useState(false); + // The SaaS session has lapsed and needs a re-sign-in (self-hosted only). + const [sessionExpired, setSessionExpired] = useState(false); const [refreshKey, setRefreshKey] = useState(0); // Stripe customer portal — the subscribed header's "Manage Payment" action. const portal = useStripePortal(wallet); @@ -65,20 +73,10 @@ export function Usage() { }, []); useEffect(() => { - // Only fetch the wallet when the instance is linked. Unlinked → render the - // link prompt; no SaaS call needed. - if (!isLinked) { - setWallet(null); - setLocalUsage(null); - setLoading(false); - setError(null); - setNeedsReauth(false); - return; - } let cancelled = false; setLoading(true); setError(null); - setNeedsReauth(false); + setSessionExpired(false); // Independent of the wallet load — a local-usage failure must not break the // page; it just means no unsynced delta is shown. fetchLocalUsage() @@ -92,18 +90,13 @@ export function Usage() { .then((w) => { if (cancelled) return; setWallet(w); - // Derive the linked-free / linked-subscribed dimension from the live - // wallet. Only refines a `linked-*` state; never flips unlinked → linked. - setLinkState( - w.status === "subscribed" ? "linked-subscribed" : "linked-free", - ); + onWalletLoaded?.(w); }) .catch((e) => { if (cancelled) return; if (e instanceof SaasNotLinkedError) { - // Reached only when the instance IS linked (we don't fetch otherwise), - // so this means the attended SaaS session expired — prompt re-sign-in. - setNeedsReauth(true); + // The attended SaaS session expired — offer a re-sign-in. + setSessionExpired(true); } else if (e instanceof SaasUnconfiguredError) { setError(e.message); } else if (e instanceof HttpError) { @@ -127,7 +120,7 @@ export function Usage() { return () => { cancelled = true; }; - }, [isLinked, refreshKey, saasSessionNonce, setLinkState]); + }, [refreshKey, onWalletLoaded, t]); const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -146,9 +139,10 @@ export function Usage() { if (!mounted.current) return false; if (w.status === "subscribed") { setWallet(w); - setLinkState("linked-subscribed"); + onWalletLoaded?.(w); // Nudge the local instance to refresh its gate now so billable work - // unblocks immediately rather than on its next poll. Fire-and-forget. + // unblocks immediately rather than on its next poll. Fire-and-forget; + // a no-op on SaaS (no local instance to sync). triggerLocalSync().catch(() => {}); return true; } @@ -162,7 +156,7 @@ export function Usage() { // shows its "almost there" notice rather than the page silently self-healing. setRefreshKey((k) => k + 1); return false; - }, [setLinkState]); + }, [onWalletLoaded]); return (

@@ -193,23 +187,23 @@ export function Usage() {
- {!isLinked && } - - {isLinked && loading && ( + {loading && (
)} - {isLinked && needsReauth && ( + {sessionExpired && ( openLinkModal("reauth")}> - {t("portal.usage.sessionExpired.action", "Sign in again")} - + onReauth ? ( + + ) : undefined } > {t( @@ -219,7 +213,7 @@ export function Usage() { )} - {isLinked && error && ( + {error && ( )} - {isLinked && portal.error && ( + {portal.error && ( )} - {isLinked && wallet && wallet.status === "free" && ( + {wallet && wallet.status === "free" && ( )} - {isLinked && wallet && wallet.status === "subscribed" && ( + {wallet && wallet.status === "subscribed" && ( interface ImportMetaEnv { - /** Hosted SaaS Supabase project URL — in-app account-link login. Empty → link UI shows a configure state. */ - readonly VITE_SAAS_SUPABASE_URL: string; - /** Hosted SaaS Supabase anon/publishable key (public). */ - readonly VITE_SAAS_SUPABASE_ANON_KEY: string; + /** Stirling Supabase project URL — in-app account-link login (same project every flavor uses). Empty → link UI shows a configure state. */ + readonly VITE_SUPABASE_URL: string; + /** Stirling Supabase publishable/anon key (public). */ + readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; /** Hosted SaaS Java backend base URL — attended portal→SaaS reads (wallet, invoices, …) via apiClient.saas with the admin's JWT. */ readonly VITE_SAAS_API_URL: string; /** Stripe publishable key (pk_live_… / pk_test_…) used by embedded Checkout. */ diff --git a/frontend/editor/src/saas/auth/supabase.ts b/frontend/editor/src/saas/auth/supabase.ts index ad32cfa58d..1ea68e077e 100644 --- a/frontend/editor/src/saas/auth/supabase.ts +++ b/frontend/editor/src/saas/auth/supabase.ts @@ -1,22 +1,10 @@ import { createClient } from "@supabase/supabase-js"; -// Debug helper to log Supabase configuration -const debugConfig = () => { - const url = import.meta.env.VITE_SUPABASE_URL; - const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; - - console.log("[Supabase Debug] Configuration:", { - url: url ? "✓ URL configured" : "✗ URL missing", - key: key ? "✓ Key configured" : "✗ Key missing", - urlValue: url || "undefined", - keyValue: key ? `${key.substring(0, 20)}...` : "undefined", - }); - - return { url, key }; +const config = { + url: import.meta.env.VITE_SUPABASE_URL, + key: import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY, }; -const config = debugConfig(); - if (!config.url) { throw new Error("Missing VITE_SUPABASE_URL environment variable"); } diff --git a/frontend/editor/src/saas/tsconfig.json b/frontend/editor/src/saas/tsconfig.json index 08beefbb77..a887c3c5c5 100644 --- a/frontend/editor/src/saas/tsconfig.json +++ b/frontend/editor/src/saas/tsconfig.json @@ -8,11 +8,19 @@ "../../src/proprietary/*", "../../src/core/*" ], - "@portal/*": ["../../src/portal/*"], + "@portal/*": ["../../src/portal-saas/*", "../../src/portal/*"], + "@portal-proprietary/*": ["../../src/portal/*"], "@cloud/*": ["../../src/cloud/*"], "@proprietary/*": ["../../src/proprietary/*"], "@core/*": ["../../src/core/*"] } }, - "include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."] + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + ".", + "../portal-saas" + ] } diff --git a/frontend/editor/tsconfig.portal.vite.json b/frontend/editor/tsconfig.portal.vite.json index 93337d1dcc..924d616019 100644 --- a/frontend/editor/tsconfig.portal.vite.json +++ b/frontend/editor/tsconfig.portal.vite.json @@ -3,12 +3,7 @@ "comment": "Path resolution for the portal's vitest project (referenced by vitest.config.ts). Broad include ('src', via the base) so vite-tsconfig-paths rewrites @app/* in every editor/src file the portal tests pull in (e.g. core/ui), not just the portal layer. The portal's own typecheck uses src/portal/tsconfig.json.", "compilerOptions": { "paths": { - "@app/*": [ - "./src/portal/*", - "./src/cloud/*", - "./src/proprietary/*", - "./src/core/*" - ], + "@app/*": ["./src/cloud/*", "./src/proprietary/*", "./src/core/*"], "@portal/*": ["./src/portal/*"], "@cloud/*": ["./src/cloud/*"], "@proprietary/*": ["./src/proprietary/*"], diff --git a/frontend/editor/tsconfig.saas.vite.json b/frontend/editor/tsconfig.saas.vite.json index c7d2c03eb2..f31279fac3 100644 --- a/frontend/editor/tsconfig.saas.vite.json +++ b/frontend/editor/tsconfig.saas.vite.json @@ -8,7 +8,8 @@ "./src/proprietary/*", "./src/core/*" ], - "@portal/*": ["./src/portal/*"], + "@portal/*": ["./src/portal-saas/*", "./src/portal/*"], + "@portal-proprietary/*": ["./src/portal/*"], "@cloud/*": ["./src/cloud/*"], "@proprietary/*": ["./src/proprietary/*"], "@core/*": ["./src/core/*"] diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts index e4dfd76277..5d7a00bccf 100644 --- a/frontend/editor/vitest.config.ts +++ b/frontend/editor/vitest.config.ts @@ -104,7 +104,13 @@ export default defineConfig({ { test: { name: "saas", - include: ["src/saas/**/*.test.{ts,tsx}"], + // src/saas = editor-saas layer; src/portal-saas = the portal's saas + // overrides (sibling to src/portal). Both build under the saas flavor, + // so both resolve @portal via the saas cascade (tsconfig.saas.vite.json). + include: [ + "src/saas/**/*.test.{ts,tsx}", + "src/portal-saas/**/*.test.{ts,tsx}", + ], environment: "jsdom", globals: true, setupFiles: ["./src/saas/setupTests.ts"], diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 02f1c68132..707c411d14 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,7 +6,8 @@ import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; const srcGlobs = [ - // The portal layer lives under editor/src/portal, so editor/src/** covers it. + // The portal layers live under editor/src/portal (base) and + // editor/src/portal-saas (saas override), so editor/src/** covers them. "editor/src/**/*.{js,mjs,jsx,ts,tsx}", ]; const nodeGlobs = [