mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900)
## 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).
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<PortalChrome />
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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(/\/+$/, "");
|
||||
}
|
||||
@@ -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(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
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(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gates while the session is still resolving", () => {
|
||||
authState.loading = true;
|
||||
render(
|
||||
<PortalAuthBoundary>
|
||||
<div data-testid="portal">PORTAL</div>
|
||||
</PortalAuthBoundary>,
|
||||
);
|
||||
expect(screen.queryByTestId("portal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FullScreen>
|
||||
<Spinner size="lg" />
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<AuthProvider mode="supabase">
|
||||
<SaasPortalGate>{children}</SaasPortalGate>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -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(<LinkAccountFooterItem />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("@portal/views/Usage", () => ({
|
||||
Usage: () => <div data-testid="usage" />,
|
||||
}));
|
||||
|
||||
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
|
||||
|
||||
describe("PortalBillingGate — SaaS", () => {
|
||||
it("renders the Usage page directly, with no link concept", () => {
|
||||
render(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("usage")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Usage
|
||||
onReauth={() => {
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 <span data-testid="tier">{usePlanTier()}</span>;
|
||||
}
|
||||
|
||||
describe("usePlanTier (SaaS) — tier from wallet", () => {
|
||||
beforeEach(() => {
|
||||
fetchWallet.mockReset();
|
||||
});
|
||||
|
||||
it("subscribed wallet → pro", async () => {
|
||||
fetchWallet.mockResolvedValue({ status: "subscribed" });
|
||||
const { getByTestId } = render(<Probe />);
|
||||
await waitFor(() => expect(getByTestId("tier").textContent).toBe("pro"));
|
||||
});
|
||||
|
||||
it("free wallet → free (also the loading default)", async () => {
|
||||
fetchWallet.mockResolvedValue({ status: "free" });
|
||||
const { getByTestId } = render(<Probe />);
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 <span data-testid="linked">{String(usePortalLinked())}</span>;
|
||||
}
|
||||
|
||||
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(<Probe />);
|
||||
expect(getByTestId("linked").textContent).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <SuiProvider colorScheme={theme}>{children}</SuiProvider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<ErrorBoundary key={pathname}>
|
||||
<ViewRouter />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* <BrowserRouter> 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() {
|
||||
<ThemedSuiProvider>
|
||||
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
|
||||
<div className="portal-scope">
|
||||
<AuthProvider mode="spring">
|
||||
<LinkProvider initialState="unlinked">
|
||||
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
|
||||
from the real link/subscription state when MSW mocks are off. */}
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AuthGate>
|
||||
<AccountLinkProvider>
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
<LinkModalHost />
|
||||
</AccountLinkProvider>
|
||||
</AuthGate>
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
</LinkProvider>
|
||||
</AuthProvider>
|
||||
<PortalAuthBoundary>
|
||||
<PortalProviders />
|
||||
</PortalAuthBoundary>
|
||||
</div>
|
||||
</ThemedSuiProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -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 (
|
||||
<LinkAccountModal
|
||||
open={linkModalOpen}
|
||||
mode={linkModalMode}
|
||||
onClose={closeLinkModal}
|
||||
onLinked={onLinked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LinkProvider initialState="unlinked">
|
||||
<TierProvider initialTier="pro">
|
||||
<UIProvider>
|
||||
<AccountLinkProvider>
|
||||
<PortalChrome />
|
||||
<LinkModalHost />
|
||||
</AccountLinkProvider>
|
||||
</UIProvider>
|
||||
</TierProvider>
|
||||
</LinkProvider>
|
||||
);
|
||||
}
|
||||
@@ -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={<Infrastructure />}
|
||||
/>
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<Usage />} />
|
||||
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
|
||||
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
|
||||
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
|
||||
@@ -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<T>(
|
||||
// saas — hosted SaaS Java, admin's Supabase JWT
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getSaasAccessToken(): Promise<string | null> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data.session?.access_token ?? null;
|
||||
}
|
||||
|
||||
async function saasJson<T>(
|
||||
path: string,
|
||||
options: HttpRequestOptions = {},
|
||||
): Promise<T> {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Base URL for attended portal→SaaS reads ({@code apiClient.saas}) — the seam the
|
||||
* SaaS build overrides.
|
||||
*
|
||||
* <p>Self-hosted (this base): the SaaS cloud is a <em>separate</em> 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.
|
||||
*
|
||||
* <p>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(/\/+$/, "");
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 (
|
||||
<AuthProvider mode="spring">
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
ensureSaasSupabase();
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const { data } = await supabase.auth.getSession();
|
||||
return data.session?.access_token ?? null;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ async function invoke<T>(
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => openLinkModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onClose={closeSettings}
|
||||
initialSection={settingsInitialSection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<ErrorBoundary key={pathname}>
|
||||
<ViewRouter />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<GlobalShortcuts />
|
||||
<AppShell>
|
||||
<RoutedContent />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: <LinkIcon size={16} />,
|
||||
},
|
||||
// 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" && <AccountLinkPanel />}
|
||||
{section === "account-link" && accountLinkSettings && (
|
||||
<accountLinkSettings.Body />
|
||||
)}
|
||||
</SettingsShell>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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: <DocsIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<NavItem
|
||||
id="account-link"
|
||||
label={t("portal.shell.sidebar.linkAccount", "Link Stirling account")}
|
||||
icon={<LinkIcon />}
|
||||
onClick={() => openLinkModal()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageFooter() {
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -49,7 +49,7 @@ export function LinkAccountCard({ link }: Props) {
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.card.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{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.",
|
||||
|
||||
@@ -96,9 +96,9 @@ export function LinkAccountModal({
|
||||
)}
|
||||
>
|
||||
{t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_URL</code>{" "}
|
||||
<code>VITE_SUPABASE_URL</code>{" "}
|
||||
{t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "}
|
||||
<code>VITE_SAAS_SUPABASE_ANON_KEY</code>{" "}
|
||||
<code>VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>{" "}
|
||||
{t(
|
||||
"portal.accountLink.modal.loginNotConfigured.after",
|
||||
"to enable in-app linking against the hosted Stirling account.",
|
||||
|
||||
@@ -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: () => <div data-testid="link-prompt" />,
|
||||
}));
|
||||
vi.mock("@portal/views/Usage", () => ({
|
||||
Usage: () => <div data-testid="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(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("link-prompt")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Usage page once linked", () => {
|
||||
linkState.isLinked = true;
|
||||
render(<PortalBillingGate />);
|
||||
expect(screen.getByTestId("usage")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 <LinkAccountPrompt />;
|
||||
return <Usage onWalletLoaded={onWalletLoaded} onReauth={onReauth} />;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: <LinkIcon size={16} />,
|
||||
Body: AccountLinkPanel,
|
||||
};
|
||||
@@ -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<TierContextValue | null>(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<Tier>(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<TierContextValue>(
|
||||
() => ({
|
||||
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 <TierContext.Provider value={value}>{children}</TierContext.Provider>;
|
||||
|
||||
@@ -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 <span data-testid="tier">{usePlanTier()}</span>;
|
||||
}
|
||||
|
||||
function renderTierFor(initialState: LinkState) {
|
||||
return render(
|
||||
<LinkProvider initialState={initialState}>
|
||||
<Probe />
|
||||
</LinkProvider>,
|
||||
).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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <span data-testid="linked">{String(usePortalLinked())}</span>;
|
||||
}
|
||||
|
||||
function renderLinkedFor(initialState: LinkState) {
|
||||
return render(
|
||||
<LinkProvider initialState={initialState}>
|
||||
<Probe />
|
||||
</LinkProvider>,
|
||||
).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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@app/*": [
|
||||
"../../src/portal/*",
|
||||
"../../src/cloud/*",
|
||||
"../../src/proprietary/*",
|
||||
"../../src/core/*"
|
||||
|
||||
@@ -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(<MantineProvider>{ui}</MantineProvider>);
|
||||
|
||||
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(<Usage onWalletLoaded={onWalletLoaded} />);
|
||||
|
||||
// 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(<Usage />);
|
||||
|
||||
await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -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<Wallet | null>(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<LocalUsage | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(isLinked);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="portal-usage portal-billing">
|
||||
@@ -193,23 +187,23 @@ export function Usage() {
|
||||
</header>
|
||||
|
||||
<div className="portal-usage__body">
|
||||
{!isLinked && <LinkAccountPrompt />}
|
||||
|
||||
{isLinked && loading && (
|
||||
{loading && (
|
||||
<div className="portal-billing__skeleton" aria-hidden>
|
||||
<Skeleton height="10rem" />
|
||||
<Skeleton height="14rem" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLinked && needsReauth && (
|
||||
{sessionExpired && (
|
||||
<Banner
|
||||
tone="warning"
|
||||
title={t("portal.usage.sessionExpired.title", "Session expired")}
|
||||
action={
|
||||
<Button size="sm" onClick={() => openLinkModal("reauth")}>
|
||||
{t("portal.usage.sessionExpired.action", "Sign in again")}
|
||||
</Button>
|
||||
onReauth ? (
|
||||
<Button size="sm" onClick={onReauth}>
|
||||
{t("portal.usage.sessionExpired.action", "Sign in again")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t(
|
||||
@@ -219,7 +213,7 @@ export function Usage() {
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && error && (
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t("portal.usage.error.loadWallet", "Couldn't load wallet")}
|
||||
@@ -228,7 +222,7 @@ export function Usage() {
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && portal.error && (
|
||||
{portal.error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
title={t(
|
||||
@@ -240,7 +234,7 @@ export function Usage() {
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{isLinked && wallet && wallet.status === "free" && (
|
||||
{wallet && wallet.status === "free" && (
|
||||
<FreePlanView
|
||||
wallet={wallet}
|
||||
unsynced={localUsage}
|
||||
@@ -248,7 +242,7 @@ export function Usage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLinked && wallet && wallet.status === "subscribed" && (
|
||||
{wallet && wallet.status === "subscribed" && (
|
||||
<SubscribedPlanView
|
||||
wallet={wallet}
|
||||
unsynced={localUsage}
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user