From 58aeba2bf7bc2aa0f771c1fa43764fb9acd473da Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Wed, 3 Jun 2026 09:03:58 +0100 Subject: [PATCH 01/80] Add backend-only and SaaS-aware frontend Dockerfiles --- .taskfiles/docker.yml | 5 ++ docker/backend/Dockerfile | 134 +++++++++++++++++++++++++++++++++++++ docker/frontend/Dockerfile | 42 +++++++++++- 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 docker/backend/Dockerfile diff --git a/.taskfiles/docker.yml b/.taskfiles/docker.yml index abe885a13..cba35493f 100644 --- a/.taskfiles/docker.yml +++ b/.taskfiles/docker.yml @@ -20,6 +20,11 @@ tasks: cmds: - docker build -t stirling-pdf-ultra-lite -f {{.EMBEDDED_DIR}}/Dockerfile.ultra-lite . + build:backend: + desc: "Build backend-only Docker image (no embedded frontend)" + cmds: + - docker build -t stirling-pdf-backend -f docker/backend/Dockerfile . + build:frontend: desc: "Build frontend-only Docker image" cmds: diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 000000000..d5bd45fb7 --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,134 @@ +# Stirling-PDF - Backend-only version (no embedded frontend) +# +# Identical to docker/embedded/Dockerfile except the JAR is built with +# -PbuildWithFrontend=false, so the React UI is NOT baked in. The gradle build +# then swaps in the lightweight API landing page (see app/core/build.gradle +# copyApiLandingPage). Use this for the split frontend/backend deployments +# (e.g. SaaS) where the UI ships as its own image (docker/frontend/Dockerfile). +# +# Because no frontend is built, the Node.js + go-task tooling that the embedded +# image installs is intentionally omitted here — the only frontend gradle tasks +# (npmInstall / npmBuild) are gated on buildWithFrontend and stay disabled. + +ARG BASE_VERSION=1.0.2 +ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} + +# Stage 1: Build the Java application (backend only, no frontend) +FROM gradle:9.3.1-jdk25@sha256:85aec999629f4774a383cb792da4b598bdf5a7e69c4b9570bb70c0f919179183 AS app-build + +# JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead +ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" + +WORKDIR /app + +COPY build.gradle settings.gradle gradlew ./ +COPY gradle/ gradle/ +COPY app/core/build.gradle app/core/ +COPY app/common/build.gradle app/common/ +COPY app/proprietary/build.gradle app/proprietary/ + +# Use system gradle instead of gradlew to avoid SSL issues downloading gradle distribution on emulated arm64 +RUN gradle dependencies --no-daemon || true + +COPY . . + +ARG PROTOTYPES_BUILD=false +ARG STIRLING_FLAVOR=proprietary +ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} + +# buildWithFrontend=false → backend-only JAR with API landing page. +RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \ + gradle clean build \ + -PbuildWithFrontend=false \ + -PprototypesMode=${PROTOTYPES_BUILD} \ + -x spotlessApply -x spotlessCheck -x test -x sonarqube \ + --no-daemon + +# Stage 2: Extract Spring Boot Layers +FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract +WORKDIR /tmp +COPY --from=app-build /app/app/core/build/libs/*.jar app.jar +RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers + + +# Stage 3: Final runtime image on top of pre-built base +FROM ${BASE_IMAGE} + +ARG VERSION_TAG + +WORKDIR /app + +# Application layers +COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/ +COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/ +COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/ +COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/ + +COPY --link --from=app-build --chown=1000:1000 \ + /app/build/libs/restart-helper.jar /restart-helper.jar +COPY --link --chown=1000:1000 scripts/ /scripts/ + +# Fonts go to system dir, root ownership is correct (world-readable) +COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ + +# Permissions and configuration +RUN set -eux; \ + chmod +x /scripts/*; \ + ln -s /logs /app/logs; \ + ln -s /configs /app/configs; \ + ln -s /customFiles /app/customFiles; \ + ln -s /pipeline /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ + chown stirlingpdfuser:stirlingpdfgroup /app; \ + chmod 750 /tmp/stirling-pdf; \ + chmod 750 /tmp/stirling-pdf/heap_dumps; \ + fc-cache -f + +# Write version to a file so it is readable by scripts without env-var inheritance. +# init-without-ocr.sh reads /etc/stirling_version for the AOT cache fingerprint. +RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version + +# Environment variables +ENV VERSION_TAG=$VERSION_TAG \ + STIRLING_AOT_ENABLE="false" \ + STIRLING_JVM_PROFILE="balanced" \ + _JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \ + _JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \ + JAVA_CUSTOM_OPTS="" \ + HOME=/home/stirlingpdfuser \ + PUID=1000 \ + PGID=1000 \ + UMASK=022 \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf \ + DBUS_SESSION_BUS_ADDRESS=/dev/null \ + SAL_TMP=/tmp/stirling-pdf/libre + +# Metadata labels +LABEL org.opencontainers.image.title="Stirling-PDF Backend" \ + org.opencontainers.image.description="Backend-only version (no embedded UI) with Calibre, LibreOffice, Tesseract, OCRmyPDF" \ + org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.vendor="Stirling-Tools" \ + org.opencontainers.image.url="https://www.stirlingpdf.com" \ + org.opencontainers.image.documentation="https://docs.stirlingpdf.com" \ + maintainer="Stirling-Tools" \ + org.opencontainers.image.authors="Stirling-Tools" \ + org.opencontainers.image.version="${VERSION_TAG}" \ + org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot" + +EXPOSE 8080/tcp +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \ + CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1 + +ENTRYPOINT ["tini", "--", "/scripts/init.sh"] +CMD [] diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index e92f19ddc..99d449321 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,3 +1,8 @@ +# check=skip=SecretsUsedInArgOrEnv +# The only ARG the SecretsUsedInArgOrEnv rule flags here is the Supabase +# PUBLISHABLE key — a client-safe value embedded in the bundle by design, not a +# secret. No real secrets are passed via ARG/ENV in this image. + # Frontend Dockerfile - React/Vite application FROM node:25-alpine@sha256:e80397b81fa93888b5f855e8bef37d9b18d3c5eb38b8731fc23d6d878647340f AS build @@ -12,8 +17,39 @@ RUN npm ci # Copy source code COPY frontend . -# Build the application (vite root is editor/, output lands in editor/dist/) -RUN npx vite build editor +# Generate the material-symbols icon subset that LocalIcon.tsx imports at build +# time. Normally produced by `task frontend:build:` via its prepare:icons +# dependency; since this Dockerfile invokes vite directly, run it explicitly. +# Fully offline — reads the @iconify-json/material-symbols dev dependency. +RUN node editor/scripts/generate-icons.js + +# ---- Build configuration (backward compatible) ---- +# STIRLING_FLAVOR selects the editor tsconfig + source set (see +# frontend/editor/vite.config.ts). VITE_BUILD_MODE selects which .env. +# files vite loads and is passed to `vite build --mode`. The defaults below +# reproduce the previous behaviour exactly: proprietary flavor, production mode +# (vite's implicit default for `vite build`). +# +# VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are CLIENT-SAFE +# publishable values, NOT secrets. They are intentionally empty here so the OSS +# repo ships no injected config; a downstream pipeline (e.g. SaaS) passes the +# target project's publishable values as build args. Note: .env* is excluded by +# .dockerignore, so these args are the only way Supabase config reaches the +# bundle in a Docker build. vite's loadEnv merges (and prioritises) VITE_- +# prefixed process env vars, so exporting them before `vite build` is enough. +ARG STIRLING_FLAVOR=proprietary +ARG VITE_BUILD_MODE=production +ARG VITE_SUPABASE_URL="" +# pragma: allowlist secret — Supabase publishable key (safe for client) +ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" + +# Build the application (vite root is editor/, output lands in editor/dist/). +# Only export the Supabase overrides when provided so unset stays unset. +RUN set -eu; \ + export STIRLING_FLAVOR="${STIRLING_FLAVOR}"; \ + if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ + if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ + npx vite build editor --mode "${VITE_BUILD_MODE}" # Production stage FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55 @@ -35,4 +71,4 @@ EXPOSE 80 ENV VITE_API_BASE_URL=http://backend:8080 # Use custom entrypoint -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] From 0b944a29a73d517a7017864812358406cc038c65 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Wed, 3 Jun 2026 09:10:08 +0100 Subject: [PATCH 02/80] Prefer Maven Central over jboss/shibboleth mirrors for resilience --- build.gradle | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3f78bc635..b131f1bd5 100644 --- a/build.gradle +++ b/build.gradle @@ -185,9 +185,14 @@ subprojects { allowInsecureProtocol = true } } + // Maven Central first: it hosts the vast majority of artifacts, so Gradle + // never has to consult the slower/less-reliable mirrors below for them. + // shibboleth + jboss remain as fallbacks for the few artifacts only they + // serve (Gradle falls through on a 404). Keeping them ahead of Central + // meant a jboss outage (503) aborted resolution of common deps. + mavenCentral() maven { url = "https://build.shibboleth.net/maven/releases" } maven { url = "https://repository.jboss.org/" } - mavenCentral() } configurations.configureEach { @@ -583,8 +588,9 @@ repositories { allowInsecureProtocol = true } } - maven { url = "https://build.shibboleth.net/maven/releases" } + // Maven Central first (see note in the subprojects repositories block above). mavenCentral() + maven { url = "https://build.shibboleth.net/maven/releases" } } dependencies { From 9da0a0d0201c883cb5e621b8f75ffee01237e04c Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Sat, 6 Jun 2026 19:20:07 +0100 Subject: [PATCH 03/80] fix: respect BASE_PATH in redirects, comparisons, and cookie consent paths --- .../core/components/filesPage/FileManagerView.tsx | 6 ++++-- .../src/core/components/shared/AppConfigModal.tsx | 3 ++- .../viewer/useViewerWorkbenchBarButtons.tsx | 11 ++--------- frontend/editor/src/core/constants/app.ts | 13 +++++++++++++ frontend/editor/src/core/hooks/useCookieConsent.ts | 6 +++--- .../editor/src/core/services/httpErrorHandler.ts | 3 ++- .../editor/src/core/utils/settingsNavigation.ts | 10 +++++----- frontend/editor/src/proprietary/auth/UseSession.tsx | 3 ++- .../src/proprietary/services/apiClientSetup.ts | 6 ++++-- frontend/editor/src/saas/services/apiClient.ts | 8 +++++--- 10 files changed, 42 insertions(+), 27 deletions(-) diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index 8b54960e3..b13315cb4 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -34,6 +34,7 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; +import { stripBasePath } from "@app/constants/app"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useFolders } from "@app/contexts/FolderContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; @@ -190,10 +191,11 @@ export default function FileManagerView() { // Push folder selection into the URL while still on /files. useEffect(() => { - if (!window.location.pathname.startsWith("/files")) return; + const stripped = stripBasePath(window.location.pathname); + if (!stripped.startsWith("/files")) return; const target = currentFolderId === null ? "/files" : `/files/${currentFolderId}`; - if (window.location.pathname !== target) { + if (stripped !== target) { navigate(target, { replace: true }); } }, [currentFolderId, navigate]); diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 3b1bd16d2..d27c74a90 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -23,6 +23,7 @@ import { useUnsavedChanges, } from "@app/contexts/UnsavedChangesContext"; import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; +import { stripBasePath } from "@app/constants/app"; interface AppConfigModalProps { opened: boolean; @@ -82,7 +83,7 @@ const AppConfigModalInner: React.FC = ({ const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined; if (detail?.key) { const alreadyInSettings = - window.location.pathname.startsWith("/settings"); + stripBasePath(window.location.pathname).startsWith("/settings"); navigate(`/settings/${detail.key}`, { replace: alreadyInSettings, }); diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 25eacf3d9..4960672dd 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -18,7 +18,7 @@ import { useNavigationState, useNavigationGuard, } from "@app/contexts/NavigationContext"; -import { BASE_PATH, withBasePath } from "@app/constants/app"; +import { stripBasePath, withBasePath } from "@app/constants/app"; import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext"; import TextFieldsIcon from "@mui/icons-material/TextFields"; import StraightenIcon from "@mui/icons-material/Straighten"; @@ -73,17 +73,10 @@ export function useViewerWorkbenchBarButtons( }); }, [registerImmediatePanUpdate]); - const stripBasePath = useCallback((path: string) => { - if (BASE_PATH && path.startsWith(BASE_PATH)) { - return path.slice(BASE_PATH.length) || "/"; - } - return path; - }, []); - const isAnnotationsPath = useCallback(() => { const cleanPath = stripBasePath(window.location.pathname).toLowerCase(); return cleanPath === "/annotations" || cleanPath.endsWith("/annotations"); - }, [stripBasePath]); + }, []); const [isAnnotationsActive, setIsAnnotationsActive] = useState(() => isAnnotationsPath(), diff --git a/frontend/editor/src/core/constants/app.ts b/frontend/editor/src/core/constants/app.ts index e8daed89c..97346c9eb 100644 --- a/frontend/editor/src/core/constants/app.ts +++ b/frontend/editor/src/core/constants/app.ts @@ -38,3 +38,16 @@ export const absoluteWithBasePath = (path: string): string => { const clean = path.startsWith("/") ? path : `/${path}`; return `${window.location.origin}${BASE_PATH}${clean}`; }; + +/** + * Remove the BASE_PATH prefix from a pathname so comparisons against + * bare app routes ("/login", "/files", …) work under any subpath deploy. + */ +export const stripBasePath = (pathname: string): string => { + if (!BASE_PATH) return pathname; + if (pathname === BASE_PATH) return "/"; + if (pathname.startsWith(`${BASE_PATH}/`)) { + return pathname.slice(BASE_PATH.length); + } + return pathname; +}; diff --git a/frontend/editor/src/core/hooks/useCookieConsent.ts b/frontend/editor/src/core/hooks/useCookieConsent.ts index b92b180b4..0ab55f02e 100644 --- a/frontend/editor/src/core/hooks/useCookieConsent.ts +++ b/frontend/editor/src/core/hooks/useCookieConsent.ts @@ -36,14 +36,14 @@ export const useCookieConsent = ({ const mainCSS = document.createElement("link"); mainCSS.rel = "stylesheet"; - mainCSS.href = `${BASE_PATH}css/cookieconsent.css`; + mainCSS.href = `${BASE_PATH}/css/cookieconsent.css`; if (!document.querySelector(`link[href="${mainCSS.href}"]`)) { document.head.appendChild(mainCSS); } const customCSS = document.createElement("link"); customCSS.rel = "stylesheet"; - customCSS.href = `${BASE_PATH}css/cookieconsentCustomisation.css`; + customCSS.href = `${BASE_PATH}/css/cookieconsentCustomisation.css`; if (!document.querySelector(`link[href="${customCSS.href}"]`)) { document.head.appendChild(customCSS); } @@ -57,7 +57,7 @@ export const useCookieConsent = ({ } const script = document.createElement("script"); - script.src = `${BASE_PATH}js/thirdParty/cookieconsent.umd.js`; + script.src = `${BASE_PATH}/js/thirdParty/cookieconsent.umd.js`; script.onload = () => { setTimeout(() => { const detectTheme = () => { diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 4783f99e2..968a44a99 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -11,6 +11,7 @@ import { clampText, extractAxiosErrorMessage, } from "@app/services/httpErrorUtils"; +import { withBasePath } from "@app/constants/app"; // Module-scoped state to reduce global variable usage const recentSpecialByEndpoint: Record = {}; @@ -82,7 +83,7 @@ export async function handleHttpError(error: any): Promise { // ignore storage access failures } const expiredPrefix = hadStoredJwt ? "expired=true&" : ""; - window.location.href = `/login?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`; + window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`; return true; // Suppress toast since we're redirecting } diff --git a/frontend/editor/src/core/utils/settingsNavigation.ts b/frontend/editor/src/core/utils/settingsNavigation.ts index b5b433d4d..443389e37 100644 --- a/frontend/editor/src/core/utils/settingsNavigation.ts +++ b/frontend/editor/src/core/utils/settingsNavigation.ts @@ -1,4 +1,5 @@ import { NavKey } from "@app/components/shared/config/types"; +import { stripBasePath, withBasePath } from "@app/constants/app"; /** * Navigate to a specific settings section @@ -13,8 +14,7 @@ import { NavKey } from "@app/components/shared/config/types"; * navigateToSettings('adminPremium'); */ export function navigateToSettings(section: NavKey) { - const basePath = window.location.pathname.split("/settings")[0] || ""; - const newPath = `${basePath}/settings/${section}`; + const newPath = withBasePath(`/settings/${section}`); window.history.pushState({}, "", newPath); // Trigger a popstate event to notify components @@ -30,10 +30,10 @@ export function navigateToSettings(section: NavKey) { * * @example * Go to People Settings - * // Returns: "/settings/people" + * // Returns: "/settings/people" (or "/bpp/settings/people" under subpath) */ export function getSettingsUrl(section: NavKey): string { - return `/settings/${section}`; + return withBasePath(`/settings/${section}`); } /** @@ -43,7 +43,7 @@ export function getSettingsUrl(section: NavKey): string { * @returns True if in settings (and matching specific section if provided) */ export function isInSettings(section?: NavKey): boolean { - const pathname = window.location.pathname; + const pathname = stripBasePath(window.location.pathname); if (!section) { return pathname.startsWith("/settings"); diff --git a/frontend/editor/src/proprietary/auth/UseSession.tsx b/frontend/editor/src/proprietary/auth/UseSession.tsx index efc846787..82ea8b63c 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/UseSession.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { springAuth } from "@app/auth/springAuthClient"; import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup"; +import { stripBasePath } from "@app/constants/app"; import type { Session, User, @@ -167,7 +168,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Clear any platform-specific cached auth on login page init. if ( typeof window !== "undefined" && - window.location.pathname.startsWith("/login") + stripBasePath(window.location.pathname).startsWith("/login") ) { await clearPlatformAuthOnLoginInit(); } diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 66a8132e8..d11f02ba9 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -1,4 +1,5 @@ import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { withBasePath } from "@app/constants/app"; let isRefreshing = false; let failedQueue: Array<{ @@ -89,9 +90,10 @@ async function refreshAuthToken(client: AxiosInstance): Promise { clearJwtTokenFromStorage(); // Redirect to login - if (window.location.pathname !== "/login") { + const loginPath = withBasePath("/login"); + if (window.location.pathname !== loginPath) { console.log("[API Client] Redirecting to login page..."); - window.location.href = "/login"; + window.location.href = loginPath; } throw error; diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 4fc8407d0..75f140828 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -3,6 +3,7 @@ import { supabase } from "@app/auth/supabase"; import { handleHttpError } from "@app/services/httpErrorHandler"; import { alert } from "@app/components/toast"; import { openPlanSettings } from "@app/utils/appSettings"; +import { withBasePath } from "@app/constants/app"; // Global credit update callback - will be set by the AuthProvider let globalCreditUpdateCallback: ((credits: number) => void) | null = null; @@ -189,7 +190,7 @@ apiClient.interceptors.response.use( if (!isPublicEndpoint) { // Redirect to login only for protected endpoints - window.location.href = "/login"; + window.location.href = withBasePath("/login"); } return Promise.reject(error); @@ -209,8 +210,9 @@ apiClient.interceptors.response.use( console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); - if (window.location.pathname !== "/login") { - window.location.href = "/login"; + const loginPath = withBasePath("/login"); + if (window.location.pathname !== loginPath) { + window.location.href = loginPath; } } } catch (refreshError) { From 940cb2fc44c078697a08174baed65d4bc8ea849f Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Sat, 6 Jun 2026 19:26:08 +0100 Subject: [PATCH 04/80] chore: trigger Cloudflare deploy From 0b575ed8411e0e14f674b18145bfe463051d614a Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Sat, 6 Jun 2026 21:21:24 +0100 Subject: [PATCH 05/80] fix: respect BASE_PATH in AI chat fetch and pdfjs worker assets --- frontend/editor/.env.saas | 5 ++--- .../src/core/workers/pixelCompareWorker.ts | 6 +++++- .../components/chat/ChatContext.tsx | 18 +++++++++++------- .../prototypes/components/chat/ChatContext.tsx | 18 +++++++++++------- frontend/editor/src/saas/utils/pathUtils.ts | 5 ++++- frontend/editor/vite-env.d.ts | 1 - 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index 2de6a435f..ca6c4b0e1 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -8,8 +8,7 @@ # Userback feedback widget — leave blank to disable VITE_USERBACK_TOKEN= -# URL subpath prefix for SaaS deployments (e.g. "app" if serving at /app/) — leave blank for root -VITE_RUN_SUBPATH= - # Development-only auth bypass — allows unauthenticated access on localhost in dev mode +# The URL subpath prefix is read from BASE_PATH at runtime, sourced from the +# top-level RUN_SUBPATH env var which Vite consumes at build time. VITE_DEV_BYPASS_AUTH=false diff --git a/frontend/editor/src/core/workers/pixelCompareWorker.ts b/frontend/editor/src/core/workers/pixelCompareWorker.ts index bb57bb75f..987857821 100644 --- a/frontend/editor/src/core/workers/pixelCompareWorker.ts +++ b/frontend/editor/src/core/workers/pixelCompareWorker.ts @@ -395,7 +395,11 @@ self.addEventListener( // the default DOMFilterFactory crashes in a worker calling document.createElementNS. // Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these // directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts). - const assetsBase = new URL("/pdfjs/", self.location.origin).toString(); + // import.meta.env.BASE_URL ends with a slash and includes any subpath prefix. + const assetsBase = new URL( + `${import.meta.env.BASE_URL}pdfjs/`, + self.location.origin, + ).toString(); const loaderOpts = (data: ArrayBuffer) => ({ data, diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 6cd473eda..65c2e9dd3 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import { generateId } from "@app/utils/generateId"; import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import apiClient from "@app/services/apiClient"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { getAuthHeaders } from "@app/services/apiClientSetup"; import { createChildStub } from "@app/contexts/file/fileActions"; import { @@ -461,13 +462,16 @@ export function ChatProvider({ children }: { children: ReactNode }) { formData.append(`conversationHistory[${i}].content`, message.content); }); - const response = await fetch("/api/v1/ai/orchestrate/stream", { - method: "POST", - body: formData, - headers: getAuthHeaders(), - credentials: "include", - signal: controller.signal, - }); + const response = await fetch( + `${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`, + { + method: "POST", + body: formData, + headers: getAuthHeaders(), + credentials: "include", + signal: controller.signal, + }, + ); if (!response.ok) { let detail: string | undefined; diff --git a/frontend/editor/src/prototypes/components/chat/ChatContext.tsx b/frontend/editor/src/prototypes/components/chat/ChatContext.tsx index 796422bc6..a6c2123ba 100644 --- a/frontend/editor/src/prototypes/components/chat/ChatContext.tsx +++ b/frontend/editor/src/prototypes/components/chat/ChatContext.tsx @@ -9,6 +9,7 @@ import { import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import { generateId } from "@app/utils/generateId"; import apiClient from "@app/services/apiClient"; +import { getApiBaseUrl } from "@app/services/apiClientConfig"; import { getAuthHeaders } from "@app/services/apiClientSetup"; import { createChildStub } from "@app/contexts/file/fileActions"; import { @@ -440,13 +441,16 @@ export function ChatProvider({ children }: { children: ReactNode }) { formData.append(`conversationHistory[${i}].content`, message.content); }); - const response = await fetch("/api/v1/ai/orchestrate/stream", { - method: "POST", - body: formData, - headers: getAuthHeaders(), - credentials: "include", - signal: controller.signal, - }); + const response = await fetch( + `${getApiBaseUrl()}/api/v1/ai/orchestrate/stream`, + { + method: "POST", + body: formData, + headers: getAuthHeaders(), + credentials: "include", + signal: controller.signal, + }, + ); if (!response.ok) { throw new Error(`AI engine request failed: ${response.status}`); diff --git a/frontend/editor/src/saas/utils/pathUtils.ts b/frontend/editor/src/saas/utils/pathUtils.ts index d2fa188e2..d746fff5b 100644 --- a/frontend/editor/src/saas/utils/pathUtils.ts +++ b/frontend/editor/src/saas/utils/pathUtils.ts @@ -1,6 +1,9 @@ import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping"; +import { BASE_PATH } from "@app/constants/app"; -const SUBPATH = import.meta.env.VITE_RUN_SUBPATH.replace(/^\/|\/$/g, ""); // "app" or "" +// BASE_PATH is "/bpp" or "" (no trailing slash). Strip the leading slash +// to match the legacy SUBPATH shape used below ("bpp" or ""). +const SUBPATH = BASE_PATH.replace(/^\//, ""); /** * Normalize pathname by stripping subpath prefix and trailing slashes diff --git a/frontend/editor/vite-env.d.ts b/frontend/editor/vite-env.d.ts index 2edad0618..d26c3bceb 100644 --- a/frontend/editor/vite-env.d.ts +++ b/frontend/editor/vite-env.d.ts @@ -14,7 +14,6 @@ interface ImportMetaEnv { // SaaS only (.env.saas) readonly VITE_USERBACK_TOKEN: string; - readonly VITE_RUN_SUBPATH: string; readonly VITE_DEV_BYPASS_AUTH: string; // Desktop only (.env.desktop) From a0b7daca52506cffb03558580d9cf76b23e5b916 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 13:37:30 +0100 Subject: [PATCH 06/80] trigger: rebuild after VITE_API_BASE_URL scope fix From 90d6ecd7e1eca2e272d3ea257274491c8c3e906d Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 15:55:40 +0100 Subject: [PATCH 07/80] debug: log env vars at build, write build-info.txt with masked markers --- frontend/editor/vite.config.ts | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 3f51f7ba3..b6594c680 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -13,6 +13,34 @@ const gzipPromise = promisify(gzip); const brotliPromise = promisify(brotliCompress); const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// TEMP DEBUG — writes a build-info.txt into dist/ on every build so we can +// curl https:////build-info.txt to confirm a fresh deploy landed +// and which env vars the build host actually received. Values are masked to +// (set)/(unset) so nothing real is exposed via the publicly-served file; the +// build log (vite-config-debug below) shows actual values for the project +// owner. Timestamp guarantees the file differs every build, forcing upload. +function buildInfoPlugin(): PluginOption { + const mask = (v: string | undefined) => (v ? "(set)" : "(unset)"); + return { + name: "debug-build-info", + apply: "build" as const, + async closeBundle() { + const distDir = path.resolve(__dirname, "dist"); + const contents = [ + `BuildTime=${new Date().toISOString()}`, + `VITE_API_BASE_URL=${mask(process.env.VITE_API_BASE_URL)}`, + `RUN_SUBPATH=${mask(process.env.RUN_SUBPATH)}`, + `VITE_SUPABASE_URL=${mask(process.env.VITE_SUPABASE_URL)}`, + `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${mask(process.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY)}`, + `STIRLING_FLAVOR=${mask(process.env.STIRLING_FLAVOR)}`, + "", + ].join("\n"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile(path.join(distDir, "build-info.txt"), contents); + }, + }; +} + function compressStaticCopyPlugin(): PluginOption { return { name: "compress-static-copy", @@ -97,6 +125,28 @@ export default defineConfig(async ({ mode }) => { // frontend/editor/ the cwd-based lookup would miss editor/.env*. const env = loadEnv(mode, import.meta.dirname, ""); + // TEMP DEBUG — surface build-time env values so we can confirm what the + // build host (Cloudflare / CI) is actually passing through. Safe to remove + // once we've stopped chasing env-var routing bugs. + // eslint-disable-next-line no-console + console.log( + "[vite-config-debug]", + JSON.stringify( + { + mode, + VITE_API_BASE_URL_processEnv: process.env.VITE_API_BASE_URL ?? "(unset)", + VITE_API_BASE_URL_loadEnv: env.VITE_API_BASE_URL ?? "(unset)", + RUN_SUBPATH: process.env.RUN_SUBPATH ?? env.RUN_SUBPATH ?? "(unset)", + VITE_SUPABASE_URL: env.VITE_SUPABASE_URL ? "(set)" : "(unset)", + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY + ? "(set)" + : "(unset)", + }, + null, + 2, + ), + ); + // Effective mode: --mode > STIRLING_FLAVOR > ENABLE_SAAS > DISABLE_ADDITIONAL_FEATURES > proprietary. const explicitMode = (VALID_MODES as readonly string[]).includes(mode) ? (mode as BuildMode) @@ -202,6 +252,7 @@ export default defineConfig(async ({ mode }) => { ], }), compressStaticCopyPlugin(), + buildInfoPlugin(), ], server: { host: true, From 290c8c2c8b279b585e52668c1f2fba12539d28aa Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 16:06:56 +0100 Subject: [PATCH 08/80] debug: enumerate VITE_/RUN_ env var names in build log --- frontend/editor/vite.config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index b6594c680..4843efd14 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -128,6 +128,9 @@ export default defineConfig(async ({ mode }) => { // TEMP DEBUG — surface build-time env values so we can confirm what the // build host (Cloudflare / CI) is actually passing through. Safe to remove // once we've stopped chasing env-var routing bugs. + const viteAndRunNames = Object.keys(process.env) + .filter((k) => k.startsWith("VITE_") || k.startsWith("RUN_")) + .sort(); // eslint-disable-next-line no-console console.log( "[vite-config-debug]", @@ -141,6 +144,11 @@ export default defineConfig(async ({ mode }) => { VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY ? "(set)" : "(unset)", + // Comprehensive list of every VITE_/RUN_ name actually present in + // process.env. Names only — values are deliberately omitted so this + // remains safe to print in the build log even if Cloudflare ever + // exposes secrets via this prefix in future. + viteAndRunEnvVarNames: viteAndRunNames, }, null, 2, From 4b2be58fab1cbb11d08b2e04689d25a91f1aa357 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 16:18:19 +0100 Subject: [PATCH 09/80] debug: fuzzy-match env var names that look like VITE_API_BASE_URL --- frontend/editor/vite.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 4843efd14..f80f8c9c5 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -131,6 +131,17 @@ export default defineConfig(async ({ mode }) => { const viteAndRunNames = Object.keys(process.env) .filter((k) => k.startsWith("VITE_") || k.startsWith("RUN_")) .sort(); + + // Fuzzy-match anything that looks like it might be a misnamed + // VITE_API_BASE_URL — typos, extra spaces, wrong case, missing + // underscores, etc. Catches names like "VITE_API_BASEURL", "vite_api_base_url", + // "VITE_API_BASE_URL " (trailing whitespace), and so on. + // Names only, no values — printed JSON-encoded so we can SEE any + // invisible characters as escape sequences. + const suspectNamesEncoded = Object.keys(process.env) + .filter((k) => /API|BASE|URL|VITE|STIRLING|BPI/i.test(k)) + .sort() + .map((k) => JSON.stringify(k)); // eslint-disable-next-line no-console console.log( "[vite-config-debug]", @@ -149,6 +160,10 @@ export default defineConfig(async ({ mode }) => { // remains safe to print in the build log even if Cloudflare ever // exposes secrets via this prefix in future. viteAndRunEnvVarNames: viteAndRunNames, + // Anything that looks like it might be a misnamed VITE_API_BASE_URL. + // JSON-encoded so trailing whitespace / unicode appears as escapes. + suspectEnvVarNamesEncoded: suspectNamesEncoded, + totalEnvVarCount: Object.keys(process.env).length, }, null, 2, From 02d923f378b9ac1edced2fc30dde9d3134b18a31 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 16:29:47 +0100 Subject: [PATCH 10/80] chore: remove env var debug from vite.config --- frontend/editor/vite.config.ts | 74 ---------------------------------- 1 file changed, 74 deletions(-) diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index f80f8c9c5..3f51f7ba3 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -13,34 +13,6 @@ const gzipPromise = promisify(gzip); const brotliPromise = promisify(brotliCompress); const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// TEMP DEBUG — writes a build-info.txt into dist/ on every build so we can -// curl https:////build-info.txt to confirm a fresh deploy landed -// and which env vars the build host actually received. Values are masked to -// (set)/(unset) so nothing real is exposed via the publicly-served file; the -// build log (vite-config-debug below) shows actual values for the project -// owner. Timestamp guarantees the file differs every build, forcing upload. -function buildInfoPlugin(): PluginOption { - const mask = (v: string | undefined) => (v ? "(set)" : "(unset)"); - return { - name: "debug-build-info", - apply: "build" as const, - async closeBundle() { - const distDir = path.resolve(__dirname, "dist"); - const contents = [ - `BuildTime=${new Date().toISOString()}`, - `VITE_API_BASE_URL=${mask(process.env.VITE_API_BASE_URL)}`, - `RUN_SUBPATH=${mask(process.env.RUN_SUBPATH)}`, - `VITE_SUPABASE_URL=${mask(process.env.VITE_SUPABASE_URL)}`, - `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${mask(process.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY)}`, - `STIRLING_FLAVOR=${mask(process.env.STIRLING_FLAVOR)}`, - "", - ].join("\n"); - await fs.mkdir(distDir, { recursive: true }); - await fs.writeFile(path.join(distDir, "build-info.txt"), contents); - }, - }; -} - function compressStaticCopyPlugin(): PluginOption { return { name: "compress-static-copy", @@ -125,51 +97,6 @@ export default defineConfig(async ({ mode }) => { // frontend/editor/ the cwd-based lookup would miss editor/.env*. const env = loadEnv(mode, import.meta.dirname, ""); - // TEMP DEBUG — surface build-time env values so we can confirm what the - // build host (Cloudflare / CI) is actually passing through. Safe to remove - // once we've stopped chasing env-var routing bugs. - const viteAndRunNames = Object.keys(process.env) - .filter((k) => k.startsWith("VITE_") || k.startsWith("RUN_")) - .sort(); - - // Fuzzy-match anything that looks like it might be a misnamed - // VITE_API_BASE_URL — typos, extra spaces, wrong case, missing - // underscores, etc. Catches names like "VITE_API_BASEURL", "vite_api_base_url", - // "VITE_API_BASE_URL " (trailing whitespace), and so on. - // Names only, no values — printed JSON-encoded so we can SEE any - // invisible characters as escape sequences. - const suspectNamesEncoded = Object.keys(process.env) - .filter((k) => /API|BASE|URL|VITE|STIRLING|BPI/i.test(k)) - .sort() - .map((k) => JSON.stringify(k)); - // eslint-disable-next-line no-console - console.log( - "[vite-config-debug]", - JSON.stringify( - { - mode, - VITE_API_BASE_URL_processEnv: process.env.VITE_API_BASE_URL ?? "(unset)", - VITE_API_BASE_URL_loadEnv: env.VITE_API_BASE_URL ?? "(unset)", - RUN_SUBPATH: process.env.RUN_SUBPATH ?? env.RUN_SUBPATH ?? "(unset)", - VITE_SUPABASE_URL: env.VITE_SUPABASE_URL ? "(set)" : "(unset)", - VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY - ? "(set)" - : "(unset)", - // Comprehensive list of every VITE_/RUN_ name actually present in - // process.env. Names only — values are deliberately omitted so this - // remains safe to print in the build log even if Cloudflare ever - // exposes secrets via this prefix in future. - viteAndRunEnvVarNames: viteAndRunNames, - // Anything that looks like it might be a misnamed VITE_API_BASE_URL. - // JSON-encoded so trailing whitespace / unicode appears as escapes. - suspectEnvVarNamesEncoded: suspectNamesEncoded, - totalEnvVarCount: Object.keys(process.env).length, - }, - null, - 2, - ), - ); - // Effective mode: --mode > STIRLING_FLAVOR > ENABLE_SAAS > DISABLE_ADDITIONAL_FEATURES > proprietary. const explicitMode = (VALID_MODES as readonly string[]).includes(mode) ? (mode as BuildMode) @@ -275,7 +202,6 @@ export default defineConfig(async ({ mode }) => { ], }), compressStaticCopyPlugin(), - buildInfoPlugin(), ], server: { host: true, From 4cd03be87a1da1ea8a072a8045d150115e3b8fc5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 16:41:09 +0100 Subject: [PATCH 11/80] fix: send Supabase token on raw fetch in SaaS chat --- .../src/core/services/apiClientSetup.ts | 8 +++- .../components/chat/ChatContext.tsx | 2 +- .../proprietary/services/apiClientSetup.ts | 11 +++-- .../components/chat/ChatContext.tsx | 2 +- .../src/saas/services/apiClientSetup.ts | 41 +++++++++++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 frontend/editor/src/saas/services/apiClientSetup.ts diff --git a/frontend/editor/src/core/services/apiClientSetup.ts b/frontend/editor/src/core/services/apiClientSetup.ts index 326978717..d30d6be0a 100644 --- a/frontend/editor/src/core/services/apiClientSetup.ts +++ b/frontend/editor/src/core/services/apiClientSetup.ts @@ -13,7 +13,11 @@ export function setupApiInterceptors(client: AxiosInstance): void { ); } -/** Auth headers for raw fetch() calls (SSE streams, etc.). Proprietary overrides with JWT + XSRF. */ -export function getAuthHeaders(): Record { +/** + * Auth headers for raw fetch() calls (SSE streams, etc.). + * Proprietary overrides with stored JWT + XSRF; SaaS overrides with the live + * Supabase access token. Async because SaaS has to consult the auth client. + */ +export async function getAuthHeaders(): Promise> { return {}; } diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 65c2e9dd3..2e3caf33d 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -467,7 +467,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { { method: "POST", body: formData, - headers: getAuthHeaders(), + headers: await getAuthHeaders(), credentials: "include", signal: controller.signal, }, diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index d11f02ba9..02708a4a5 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -100,8 +100,11 @@ async function refreshAuthToken(client: AxiosInstance): Promise { } } -/** Auth headers for raw fetch() calls (SSE streams, etc.). */ -export function getAuthHeaders(): Record { +/** + * Auth headers for raw fetch() calls (SSE streams, etc.). + * Async to match the SaaS override, which has to await the Supabase session. + */ +export async function getAuthHeaders(): Promise> { const headers: Record = {}; const jwt = getJwtTokenFromStorage(); if (jwt) { @@ -117,8 +120,8 @@ export function getAuthHeaders(): Record { export function setupApiInterceptors(client: AxiosInstance): void { // Install request interceptor to add JWT token client.interceptors.request.use( - (config) => { - const authHeaders = getAuthHeaders(); + async (config) => { + const authHeaders = await getAuthHeaders(); for (const [key, value] of Object.entries(authHeaders)) { if (!config.headers[key]) { config.headers[key] = value; diff --git a/frontend/editor/src/prototypes/components/chat/ChatContext.tsx b/frontend/editor/src/prototypes/components/chat/ChatContext.tsx index a6c2123ba..cd0142e3c 100644 --- a/frontend/editor/src/prototypes/components/chat/ChatContext.tsx +++ b/frontend/editor/src/prototypes/components/chat/ChatContext.tsx @@ -446,7 +446,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { { method: "POST", body: formData, - headers: getAuthHeaders(), + headers: await getAuthHeaders(), credentials: "include", signal: controller.signal, }, diff --git a/frontend/editor/src/saas/services/apiClientSetup.ts b/frontend/editor/src/saas/services/apiClientSetup.ts new file mode 100644 index 000000000..e84fb6bb9 --- /dev/null +++ b/frontend/editor/src/saas/services/apiClientSetup.ts @@ -0,0 +1,41 @@ +import type { AxiosInstance } from "axios"; +import { supabase } from "@app/auth/supabase"; + +/** + * SaaS auth headers for raw fetch() calls (e.g. AI chat streaming). + * + * Pulls the live Supabase access token. Required because the SaaS apiClient's + * axios interceptor attaches this header to every axios call, but raw fetch() + * calls bypass that path and end up with no Authorization header → backend + * returns 401. The chat streaming endpoint uses fetch() (not axios) because + * axios doesn't stream SSE responses well, so this override exists to give + * it the same bearer token the axios calls already get. + * + * supabase.auth.getSession() reads from in-memory cache when possible; only + * issues a network request if the session needs refreshing. Adds an Accept + * header so the backend negotiates JSON correctly. + */ +export async function getAuthHeaders(): Promise> { + const headers: Record = {}; + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + if (session?.access_token) { + headers["Authorization"] = `Bearer ${session.access_token}`; + } + } catch (e) { + console.warn("[apiClientSetup] Failed to read Supabase session", e); + } + return headers; +} + +/** + * SaaS apiClient wires up its own interceptors inline (see saas/services/apiClient.ts). + * This re-export exists so the cascade through @app/services/apiClientSetup + * remains consistent for callers that import setupApiInterceptors — currently + * none in SaaS mode, but keeps the shape uniform. + */ +export function setupApiInterceptors(_client: AxiosInstance): void { + // No-op: SaaS apiClient handles its own interceptors with the Supabase session. +} From d9651f7065cf8d78c1948d8c209f223fd2ed0dcb Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 18:02:02 +0100 Subject: [PATCH 12/80] fix(engine): match Dockerfile layout to root Taskfile dir: engine --- engine/Dockerfile | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/engine/Dockerfile b/engine/Dockerfile index c9c8d2239..2f826120b 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -10,17 +10,40 @@ RUN apt-get update \ && rm /tmp/task.deb \ && rm -rf /var/lib/apt/lists/* -WORKDIR /app +# The OSS repo's root Taskfile.yml expects engine tasks to run from ./engine/ +# (configured via `includes.engine.dir: engine`). The OSS-clone-build workflow +# copies the root Taskfile.yml + .taskfiles/ into the engine build context, then +# this Dockerfile copies them into the image. To match the Taskfile's expectations +# at runtime, engine source must live at /app/engine/ — not flattened to /app/ — +# so `task engine:run` can chdir there successfully and uv resolves +# scripts/setup_env.py to /app/engine/scripts/setup_env.py. +# +# Layout: +# /app/Taskfile.yml ← workflow-copied root Taskfile +# /app/.taskfiles/ ← workflow-copied .taskfiles +# /app/engine/pyproject.toml +# /app/engine/uv.lock +# /app/engine/.env +# /app/engine/scripts/ ← scripts/setup_env.py here +# /app/engine/src/ ← uvicorn entrypoint dir +# /app/engine/.venv/ ← uv-managed venv +WORKDIR /app/engine -COPY pyproject.toml uv.lock Taskfile.yml .env ./ -COPY .taskfiles/ ./.taskfiles/ +COPY pyproject.toml uv.lock .env ./ COPY scripts/ ./scripts/ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev COPY src/ ./src/ -ENV PATH="/app/.venv/bin:$PATH" +# Task definitions live at /app/ — root Taskfile + included .taskfiles — because +# the root Taskfile's `includes.engine.dir: engine` is resolved relative to the +# Taskfile's own location, so it must sit one level above engine/. +WORKDIR /app +COPY Taskfile.yml ./ +COPY .taskfiles/ ./.taskfiles/ + +ENV PATH="/app/engine/.venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 EXPOSE 5001 From 1d5ce8a1d231022b1ed989153010c137502b9723 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 18:38:00 +0100 Subject: [PATCH 13/80] chore: shorten verbose block comments across SaaS branch --- build.gradle | 7 +-- docker/backend/Dockerfile | 15 +------ docker/frontend/Dockerfile | 44 +++---------------- engine/Dockerfile | 21 +-------- frontend/editor/.env.saas | 4 +- frontend/editor/src/core/constants/app.ts | 5 +-- .../src/core/services/apiClientSetup.ts | 6 +-- .../src/core/utils/settingsNavigation.ts | 40 ++--------------- .../src/core/workers/pixelCompareWorker.ts | 7 +-- .../proprietary/services/apiClientSetup.ts | 5 +-- .../src/saas/services/apiClientSetup.ts | 26 ++--------- frontend/editor/src/saas/utils/pathUtils.ts | 3 +- 12 files changed, 24 insertions(+), 159 deletions(-) diff --git a/build.gradle b/build.gradle index 40714ed9b..d67485dff 100644 --- a/build.gradle +++ b/build.gradle @@ -185,11 +185,7 @@ subprojects { allowInsecureProtocol = true } } - // Maven Central first: it hosts the vast majority of artifacts, so Gradle - // never has to consult the slower/less-reliable mirrors below for them. - // shibboleth + jboss remain as fallbacks for the few artifacts only they - // serve (Gradle falls through on a 404). Keeping them ahead of Central - // meant a jboss outage (503) aborted resolution of common deps. + // Maven Central first; mirrors below are fallbacks for niche artifacts. mavenCentral() maven { url = "https://build.shibboleth.net/maven/releases" } maven { url = "https://repository.jboss.org/" } @@ -588,7 +584,6 @@ repositories { allowInsecureProtocol = true } } - // Maven Central first (see note in the subprojects repositories block above). mavenCentral() maven { url = "https://build.shibboleth.net/maven/releases" } } diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index d5bd45fb7..4cc63ddda 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -1,14 +1,4 @@ -# Stirling-PDF - Backend-only version (no embedded frontend) -# -# Identical to docker/embedded/Dockerfile except the JAR is built with -# -PbuildWithFrontend=false, so the React UI is NOT baked in. The gradle build -# then swaps in the lightweight API landing page (see app/core/build.gradle -# copyApiLandingPage). Use this for the split frontend/backend deployments -# (e.g. SaaS) where the UI ships as its own image (docker/frontend/Dockerfile). -# -# Because no frontend is built, the Node.js + go-task tooling that the embedded -# image installs is intentionally omitted here — the only frontend gradle tasks -# (npmInstall / npmBuild) are gated on buildWithFrontend and stay disabled. +# Stirling-PDF backend-only image — JAR built with -PbuildWithFrontend=false, UI ships separately. ARG BASE_VERSION=1.0.2 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} @@ -89,8 +79,7 @@ RUN set -eux; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ fc-cache -f -# Write version to a file so it is readable by scripts without env-var inheritance. -# init-without-ocr.sh reads /etc/stirling_version for the AOT cache fingerprint. +# Version file for scripts (init-without-ocr.sh reads /etc/stirling_version). RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version # Environment variables diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 99d449321..67e35f82c 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,74 +1,44 @@ # check=skip=SecretsUsedInArgOrEnv -# The only ARG the SecretsUsedInArgOrEnv rule flags here is the Supabase -# PUBLISHABLE key — a client-safe value embedded in the bundle by design, not a -# secret. No real secrets are passed via ARG/ENV in this image. +# Supabase publishable ARG is client-safe by design, not a real secret. -# Frontend Dockerfile - React/Vite application +# Stage 1: build FROM node:25-alpine@sha256:e80397b81fa93888b5f855e8bef37d9b18d3c5eb38b8731fc23d6d878647340f AS build WORKDIR /app -# Copy package files COPY frontend/package.json frontend/package-lock.json ./ - -# Install dependencies RUN npm ci -# Copy source code COPY frontend . -# Generate the material-symbols icon subset that LocalIcon.tsx imports at build -# time. Normally produced by `task frontend:build:` via its prepare:icons -# dependency; since this Dockerfile invokes vite directly, run it explicitly. -# Fully offline — reads the @iconify-json/material-symbols dev dependency. +# Generate material-symbols icon subset (normally done by task prepare:icons). RUN node editor/scripts/generate-icons.js -# ---- Build configuration (backward compatible) ---- -# STIRLING_FLAVOR selects the editor tsconfig + source set (see -# frontend/editor/vite.config.ts). VITE_BUILD_MODE selects which .env. -# files vite loads and is passed to `vite build --mode`. The defaults below -# reproduce the previous behaviour exactly: proprietary flavor, production mode -# (vite's implicit default for `vite build`). -# -# VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are CLIENT-SAFE -# publishable values, NOT secrets. They are intentionally empty here so the OSS -# repo ships no injected config; a downstream pipeline (e.g. SaaS) passes the -# target project's publishable values as build args. Note: .env* is excluded by -# .dockerignore, so these args are the only way Supabase config reaches the -# bundle in a Docker build. vite's loadEnv merges (and prioritises) VITE_- -# prefixed process env vars, so exporting them before `vite build` is enough. +# Defaults match prior behaviour. Supabase values are client-publishable build args. ARG STIRLING_FLAVOR=proprietary ARG VITE_BUILD_MODE=production ARG VITE_SUPABASE_URL="" -# pragma: allowlist secret — Supabase publishable key (safe for client) +# pragma: allowlist secret ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" -# Build the application (vite root is editor/, output lands in editor/dist/). -# Only export the Supabase overrides when provided so unset stays unset. +# Build vite from editor/, output lands in editor/dist/. RUN set -eu; \ export STIRLING_FLAVOR="${STIRLING_FLAVOR}"; \ if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ npx vite build editor --mode "${VITE_BUILD_MODE}" -# Production stage +# Stage 2: nginx FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55 -# Copy built files from build stage COPY --from=build /app/editor/dist /usr/share/nginx/html - -# Copy nginx configuration and entrypoint COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf COPY docker/frontend/entrypoint.sh /entrypoint.sh -# Make entrypoint executable RUN chmod +x /entrypoint.sh -# Expose port 80 (standard HTTP port) EXPOSE 80 -# Environment variables for flexibility ENV VITE_API_BASE_URL=http://backend:8080 -# Use custom entrypoint ENTRYPOINT ["/entrypoint.sh"] diff --git a/engine/Dockerfile b/engine/Dockerfile index 2f826120b..5a0a835fa 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -10,23 +10,7 @@ RUN apt-get update \ && rm /tmp/task.deb \ && rm -rf /var/lib/apt/lists/* -# The OSS repo's root Taskfile.yml expects engine tasks to run from ./engine/ -# (configured via `includes.engine.dir: engine`). The OSS-clone-build workflow -# copies the root Taskfile.yml + .taskfiles/ into the engine build context, then -# this Dockerfile copies them into the image. To match the Taskfile's expectations -# at runtime, engine source must live at /app/engine/ — not flattened to /app/ — -# so `task engine:run` can chdir there successfully and uv resolves -# scripts/setup_env.py to /app/engine/scripts/setup_env.py. -# -# Layout: -# /app/Taskfile.yml ← workflow-copied root Taskfile -# /app/.taskfiles/ ← workflow-copied .taskfiles -# /app/engine/pyproject.toml -# /app/engine/uv.lock -# /app/engine/.env -# /app/engine/scripts/ ← scripts/setup_env.py here -# /app/engine/src/ ← uvicorn entrypoint dir -# /app/engine/.venv/ ← uv-managed venv +# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`. WORKDIR /app/engine COPY pyproject.toml uv.lock .env ./ @@ -36,9 +20,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY src/ ./src/ -# Task definitions live at /app/ — root Taskfile + included .taskfiles — because -# the root Taskfile's `includes.engine.dir: engine` is resolved relative to the -# Taskfile's own location, so it must sit one level above engine/. WORKDIR /app COPY Taskfile.yml ./ COPY .taskfiles/ ./.taskfiles/ diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index ca6c4b0e1..ee182fe99 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -8,7 +8,5 @@ # Userback feedback widget — leave blank to disable VITE_USERBACK_TOKEN= -# Development-only auth bypass — allows unauthenticated access on localhost in dev mode -# The URL subpath prefix is read from BASE_PATH at runtime, sourced from the -# top-level RUN_SUBPATH env var which Vite consumes at build time. +# Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time). VITE_DEV_BYPASS_AUTH=false diff --git a/frontend/editor/src/core/constants/app.ts b/frontend/editor/src/core/constants/app.ts index 97346c9eb..abb9b3f94 100644 --- a/frontend/editor/src/core/constants/app.ts +++ b/frontend/editor/src/core/constants/app.ts @@ -39,10 +39,7 @@ export const absoluteWithBasePath = (path: string): string => { return `${window.location.origin}${BASE_PATH}${clean}`; }; -/** - * Remove the BASE_PATH prefix from a pathname so comparisons against - * bare app routes ("/login", "/files", …) work under any subpath deploy. - */ +/** Strip BASE_PATH prefix so route comparisons work under any subpath deploy. */ export const stripBasePath = (pathname: string): string => { if (!BASE_PATH) return pathname; if (pathname === BASE_PATH) return "/"; diff --git a/frontend/editor/src/core/services/apiClientSetup.ts b/frontend/editor/src/core/services/apiClientSetup.ts index d30d6be0a..95f5971a1 100644 --- a/frontend/editor/src/core/services/apiClientSetup.ts +++ b/frontend/editor/src/core/services/apiClientSetup.ts @@ -13,11 +13,7 @@ export function setupApiInterceptors(client: AxiosInstance): void { ); } -/** - * Auth headers for raw fetch() calls (SSE streams, etc.). - * Proprietary overrides with stored JWT + XSRF; SaaS overrides with the live - * Supabase access token. Async because SaaS has to consult the auth client. - */ +/** Auth headers for raw fetch() calls — empty in core; proprietary/SaaS override. */ export async function getAuthHeaders(): Promise> { return {}; } diff --git a/frontend/editor/src/core/utils/settingsNavigation.ts b/frontend/editor/src/core/utils/settingsNavigation.ts index 443389e37..fddd9ab5b 100644 --- a/frontend/editor/src/core/utils/settingsNavigation.ts +++ b/frontend/editor/src/core/utils/settingsNavigation.ts @@ -1,53 +1,21 @@ import { NavKey } from "@app/components/shared/config/types"; import { stripBasePath, withBasePath } from "@app/constants/app"; -/** - * Navigate to a specific settings section - * - * @param section - The settings section key to navigate to - * - * @example - * // Navigate to People section - * navigateToSettings('people'); - * - * // Navigate to Admin Premium section - * navigateToSettings('adminPremium'); - */ +/** Push the URL for a settings section and notify listeners. */ export function navigateToSettings(section: NavKey) { const newPath = withBasePath(`/settings/${section}`); window.history.pushState({}, "", newPath); - - // Trigger a popstate event to notify components window.dispatchEvent(new PopStateEvent("popstate")); } -/** - * Get the URL path for a settings section - * Useful for creating links - * - * @param section - The settings section key - * @returns The URL path for the settings section - * - * @example - * Go to People Settings - * // Returns: "/settings/people" (or "/bpp/settings/people" under subpath) - */ +/** URL for a settings section (subpath-aware). */ export function getSettingsUrl(section: NavKey): string { return withBasePath(`/settings/${section}`); } -/** - * Check if currently viewing a settings section - * - * @param section - Optional section key to check for specific section - * @returns True if in settings (and matching specific section if provided) - */ +/** Whether the current URL is in /settings (optionally a specific section). */ export function isInSettings(section?: NavKey): boolean { const pathname = stripBasePath(window.location.pathname); - - if (!section) { - return pathname.startsWith("/settings"); - } - + if (!section) return pathname.startsWith("/settings"); return pathname === `/settings/${section}`; } diff --git a/frontend/editor/src/core/workers/pixelCompareWorker.ts b/frontend/editor/src/core/workers/pixelCompareWorker.ts index 987857821..c1390391b 100644 --- a/frontend/editor/src/core/workers/pixelCompareWorker.ts +++ b/frontend/editor/src/core/workers/pixelCompareWorker.ts @@ -390,12 +390,7 @@ self.addEventListener( let compDoc: PDFDocumentProxy | null = null; try { - // `CanvasFactory`/`FilterFactory` are supported at runtime but not declared on legacy types. - // pdfjs-dist 5.x renamed the option to `CanvasFactory` (capital C); without a FilterFactory - // the default DOMFilterFactory crashes in a worker calling document.createElementNS. - // Resolve CMap/standard-font URLs against the worker's own origin. Vite copies these - // directories from pdfjs-dist at build time via viteStaticCopy (see vite.config.ts). - // import.meta.env.BASE_URL ends with a slash and includes any subpath prefix. + // Resolve pdfjs CMap/standard-font URLs against the worker's origin + subpath. const assetsBase = new URL( `${import.meta.env.BASE_URL}pdfjs/`, self.location.origin, diff --git a/frontend/editor/src/proprietary/services/apiClientSetup.ts b/frontend/editor/src/proprietary/services/apiClientSetup.ts index 02708a4a5..18b13861d 100644 --- a/frontend/editor/src/proprietary/services/apiClientSetup.ts +++ b/frontend/editor/src/proprietary/services/apiClientSetup.ts @@ -100,10 +100,7 @@ async function refreshAuthToken(client: AxiosInstance): Promise { } } -/** - * Auth headers for raw fetch() calls (SSE streams, etc.). - * Async to match the SaaS override, which has to await the Supabase session. - */ +/** Auth headers for raw fetch() calls (SSE streams). Async to match SaaS override. */ export async function getAuthHeaders(): Promise> { const headers: Record = {}; const jwt = getJwtTokenFromStorage(); diff --git a/frontend/editor/src/saas/services/apiClientSetup.ts b/frontend/editor/src/saas/services/apiClientSetup.ts index e84fb6bb9..eaed3ec19 100644 --- a/frontend/editor/src/saas/services/apiClientSetup.ts +++ b/frontend/editor/src/saas/services/apiClientSetup.ts @@ -1,20 +1,7 @@ import type { AxiosInstance } from "axios"; import { supabase } from "@app/auth/supabase"; -/** - * SaaS auth headers for raw fetch() calls (e.g. AI chat streaming). - * - * Pulls the live Supabase access token. Required because the SaaS apiClient's - * axios interceptor attaches this header to every axios call, but raw fetch() - * calls bypass that path and end up with no Authorization header → backend - * returns 401. The chat streaming endpoint uses fetch() (not axios) because - * axios doesn't stream SSE responses well, so this override exists to give - * it the same bearer token the axios calls already get. - * - * supabase.auth.getSession() reads from in-memory cache when possible; only - * issues a network request if the session needs refreshing. Adds an Accept - * header so the backend negotiates JSON correctly. - */ +/** SaaS auth headers for raw fetch() calls — Supabase access token from current session. */ export async function getAuthHeaders(): Promise> { const headers: Record = {}; try { @@ -30,12 +17,5 @@ export async function getAuthHeaders(): Promise> { return headers; } -/** - * SaaS apiClient wires up its own interceptors inline (see saas/services/apiClient.ts). - * This re-export exists so the cascade through @app/services/apiClientSetup - * remains consistent for callers that import setupApiInterceptors — currently - * none in SaaS mode, but keeps the shape uniform. - */ -export function setupApiInterceptors(_client: AxiosInstance): void { - // No-op: SaaS apiClient handles its own interceptors with the Supabase session. -} +/** No-op: SaaS apiClient wires its own interceptors (see saas/services/apiClient.ts). */ +export function setupApiInterceptors(_client: AxiosInstance): void {} diff --git a/frontend/editor/src/saas/utils/pathUtils.ts b/frontend/editor/src/saas/utils/pathUtils.ts index d746fff5b..1055df746 100644 --- a/frontend/editor/src/saas/utils/pathUtils.ts +++ b/frontend/editor/src/saas/utils/pathUtils.ts @@ -1,8 +1,7 @@ import { URL_TO_TOOL_MAP } from "@app/utils/urlMapping"; import { BASE_PATH } from "@app/constants/app"; -// BASE_PATH is "/bpp" or "" (no trailing slash). Strip the leading slash -// to match the legacy SUBPATH shape used below ("bpp" or ""). +// "bpp" or "" — BASE_PATH without leading slash. const SUBPATH = BASE_PATH.replace(/^\//, ""); /** From 92376b738257ecc5c6c440e23ef67c81f3c540f1 Mon Sep 17 00:00:00 2001 From: Anthony Stirling Date: Mon, 8 Jun 2026 21:28:13 +0100 Subject: [PATCH 14/80] fix: prettier format on AppConfigModal --- .../editor/src/core/components/shared/AppConfigModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 95a3ebc59..3b6bb4a9c 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -84,8 +84,9 @@ const AppConfigModalInner: React.FC = ({ const handler = (ev: Event) => { const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined; if (detail?.key) { - const alreadyInSettings = - stripBasePath(window.location.pathname).startsWith("/settings"); + const alreadyInSettings = stripBasePath( + window.location.pathname, + ).startsWith("/settings"); navigate(`/settings/${detail.key}`, { replace: alreadyInSettings, }); From 1135bd9b63311bbab0c79b5d52cbe2ecb38a7c2f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:13:28 +0100 Subject: [PATCH 15/80] =?UTF-8?q?fix(saas):=20stop=20login=E2=86=92logout?= =?UTF-8?q?=E2=86=92login=20bounce=20on=20cold=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On returning to the app with an expired Supabase access token, bootstrap requests fired with the stale token and 401'd before Supabase finished refreshing. The global 401 handler then hard-redirected to /login?from=… (a full window.location navigation), and once the refresh landed the app sent the user straight back in — the login/logout/login flicker. Two holes in the SaaS apiClient response interceptor caused it: 1. "public" endpoints (e.g. /api/v1/config/app-config) skipped the refresh-and-retry path. The backend 401s any expired Bearer token regardless of route, so those bootstrap calls 401'd and fell through to handleHttpError, which redirected to /login. Now public endpoints also refresh-and-retry, and a 401 on a public endpoint sets skipAuthRedirect so it can never trigger the global login redirect. 2. Concurrent 401s each called supabase.auth.refreshSession() independently. Supabase rotates the refresh token on first use, so the racing refreshes failed with "Invalid Refresh Token: Already Used" and bounced the app even though the session was recoverable. Refreshes are now de-duplicated through a single in-flight promise. Existing apiClient unit tests (refresh-and-retry on protected 401, bare /login redirect on genuine refresh failure) are preserved. Co-Authored-By: Claude Opus 4.8 --- .../editor/src/saas/services/apiClient.ts | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 75f140828..f484b28f8 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -119,6 +119,28 @@ const publicEndpoints = [ "/api/v1/config/endpoints-enabled", ]; +// De-duplicate concurrent token refreshes. +// +// On a cold load with an expired access token, many bootstrap requests +// (config, credits, footer-info, storage, ...) 401 at roughly the same +// instant. If each one calls supabase.auth.refreshSession() independently, +// Supabase rotates the refresh token on first use and the remaining +// concurrent refreshes fail with "Invalid Refresh Token: Already Used". +// That spurious failure then bounced the whole app to /login even though the +// session was perfectly recoverable. Sharing a single in-flight refresh +// promise makes the first 401 do the network refresh and everyone else awaits +// the same result. +let inFlightRefresh: ReturnType | null = + null; +function refreshSessionOnce(): ReturnType { + if (!inFlightRefresh) { + inFlightRefresh = supabase.auth.refreshSession().finally(() => { + inFlightRefresh = null; + }); + } + return inFlightRefresh; +} + // Response interceptor for handling token refresh and credit updates apiClient.interceptors.response.use( (response) => { @@ -154,17 +176,19 @@ apiClient.interceptors.response.use( return response; }, async (error) => { - const originalRequest = error.config; + const originalRequest = error.config || {}; + const status = error.response?.status; const isPublicEndpoint = publicEndpoints.some((endpoint) => originalRequest.url?.includes(endpoint), ); - // If we get a 401 and haven't already tried to refresh, and it's not a public endpoint - if ( - error.response?.status === 401 && - !originalRequest._retry && - !isPublicEndpoint - ) { + // On a 401 (that we haven't already retried), try to recover the session + // before giving up. We attempt this for BOTH protected and public + // endpoints: the backend rejects any expired Bearer token regardless of + // route, so a "public" bootstrap call (e.g. /api/v1/config/app-config) can + // 401 on cold load too. Refreshing + retrying lets those succeed instead of + // tripping the global login redirect. + if (status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { @@ -178,18 +202,14 @@ apiClient.interceptors.response.use( const { data: { session: refreshedSession }, error: refreshError, - } = await supabase.auth.refreshSession(); + } = await refreshSessionOnce(); if (refreshError) { console.error("[API Client] Token refresh failed:", refreshError); - // Only redirect to login for protected endpoints, not public ones - const isPublicEndpoint = - originalRequest.url?.includes("/api/v1/config/") || - originalRequest.url?.includes("/api/v1/info/"); - + // The session genuinely can't be recovered. Send protected requests + // to login; public ones just fail quietly (no redirect). if (!isPublicEndpoint) { - // Redirect to login only for protected endpoints window.location.href = withBasePath("/login"); } @@ -205,8 +225,9 @@ apiClient.interceptors.response.use( // Retry the original request with the new token return apiClient(originalRequest); } - } else { - // No session exists, only redirect if not already on login page + } else if (!isPublicEndpoint) { + // No session exists on a protected endpoint, only redirect if not + // already on the login page. console.debug( "[API Client] No session to refresh, 401 on protected endpoint", ); @@ -214,20 +235,25 @@ apiClient.interceptors.response.use( if (window.location.pathname !== loginPath) { window.location.href = loginPath; } + return Promise.reject(error); } } catch (refreshError) { console.error("[API Client] Error during token refresh:", refreshError); } } - // For public endpoints with 401, just log and continue (don't redirect) - if (isPublicEndpoint && error.response?.status === 401) { + // A 401 on a public endpoint must never trigger the global login redirect. + // If we reach here it means refresh/retry didn't resolve it (e.g. no + // session yet, or it 401'd again); suppress the redirect in the shared + // error handler so a transient bootstrap 401 can't bounce the app to + // /login while Supabase is still restoring the session. + if (status === 401 && isPublicEndpoint) { console.debug( "[API Client] 401 on public endpoint, continuing without auth:", originalRequest.url, ); + originalRequest.skipAuthRedirect = true; } - const status = error.response?.status; const url = error.config?.url; const method = error.config?.method?.toUpperCase(); From 06476ea69e19cc180bcb2e28bd48f1b6bbc7d3f1 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:26:27 +0100 Subject: [PATCH 16/80] fix(saas): collapse duplicate Supabase client to one GoTrueClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console warned "Multiple GoTrueClient instances detected in the same browser context" and storage endpoints (/api/v1/storage/folders, /files) kept 401ing even after a successful token refresh. Cause: the SaaS bundle instantiated TWO Supabase clients on the same sb--auth-token storage key. :saas/auth/supabase.ts creates the primary client (used by UseSession + apiClient), while billing / licensing / user-management code imports @app/services/supabaseClient, which fell through to :proprietary/services/supabaseClient.ts and called createClient() again. Each client runs its own autoRefreshToken timer, so they rotate the refresh token out from under each other → "Already Used" refresh failures and spurious 401s, plus a residual /login flash. Add a :saas override of @app/services/supabaseClient that re-exports the single instance from @app/auth/supabase. The path mapping (@app/* → src/saas/* → src/proprietary/* → src/core/*) now resolves every consumer to the same client, so the :proprietary createClient() is never bundled in the SaaS build. Co-Authored-By: Claude Opus 4.8 --- .../src/saas/services/supabaseClient.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 frontend/editor/src/saas/services/supabaseClient.ts diff --git a/frontend/editor/src/saas/services/supabaseClient.ts b/frontend/editor/src/saas/services/supabaseClient.ts new file mode 100644 index 000000000..e3a91558f --- /dev/null +++ b/frontend/editor/src/saas/services/supabaseClient.ts @@ -0,0 +1,22 @@ +// SaaS-layer override for `@app/services/supabaseClient`. +// +// There must be exactly ONE Supabase client (one GoTrueClient) per browser +// context. The :proprietary version of this module calls createClient() a +// second time with the same auth-token storage key as :saas's +// `@app/auth/supabase`. In the SaaS bundle, billing/licensing/user-management +// code (CheckoutContext, licenseService, AdminPlanSection, userManagementService) +// imports `@app/services/supabaseClient` and would otherwise pull in that second +// client. Two clients => two independent autoRefreshToken timers racing on the +// same refresh token => "Multiple GoTrueClient instances detected", rotated / +// "Already Used" refresh tokens, and spurious 401s (e.g. /api/v1/storage/*). +// +// Re-exporting the singleton from `@app/auth/supabase` keeps every +// `@app/services/supabaseClient` consumer pointed at the same instance, so the +// :proprietary module's createClient() is never bundled in the SaaS build. +import type { SupabaseClient } from "@supabase/supabase-js"; +import { supabase as supabaseSingleton } from "@app/auth/supabase"; + +// `@app/auth/supabase` throws on missing config, so in the SaaS build the +// client is always present and Supabase is always configured. +export const supabase: SupabaseClient | null = supabaseSingleton; +export const isSupabaseConfigured = true; From 7f7c86588813e3da758a3d0ae0e16f3ee44e3715 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:56:20 +0100 Subject: [PATCH 17/80] fix: stop unauthenticated storage calls on /login + fix subpath manifest 404 Two issues seen on the hosted /bpp login screen: 1. GET /api/v1/storage/folders fired (and 401'd) on the login page. The global FolderProvider pulls from the server whenever appConfig.storageEnabled is true, with no auth gate, so it hits the authenticated storage API before the user has signed in. Skip the pull on auth routes (/login, /signup, /auth/*, /invite, /reset-password), mirroring the existing LicenseContext / AppConfigContext guards. Tests wrap FolderProvider in MemoryRouter (now uses useLocation). 2. manifest.json and modern-logo/favicon.ico 404'd from the domain root instead of /bpp/. vite base for RUN_SUBPATH deploys was "/bpp" with no trailing slash, so made the browser resolve relative links against the parent (root). Use "/bpp/"; getBasePath() strips the trailing slash, so BASE_PATH, routing and asset URLs are unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/core/contexts/FolderContext.test.tsx | 17 +++++++++++------ .../editor/src/core/contexts/FolderContext.tsx | 13 +++++++++++-- frontend/editor/vite.config.ts | 7 ++++++- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/frontend/editor/src/core/contexts/FolderContext.test.tsx b/frontend/editor/src/core/contexts/FolderContext.test.tsx index 46de8f6d7..1734bf350 100644 --- a/frontend/editor/src/core/contexts/FolderContext.test.tsx +++ b/frontend/editor/src/core/contexts/FolderContext.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { describe, expect, test, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { FolderProvider, useFolders } from "@app/contexts/FolderContext"; import { createFolderId, FolderId, FolderRecord } from "@app/types/folder"; @@ -110,9 +111,11 @@ function axiosError(status: number, message = "rejected"): Error { async function renderAndWaitForPull(): Promise { render( - - - , + + + + + , ); // The pull is fired from a mount effect; wait until it has resolved by // observing that `mockList` was called at least once and a tick has @@ -241,9 +244,11 @@ describe("FolderContext stale-folder 404 cleanup", () => { mockList.mockResolvedValueOnce(initial); const apiRef: { current: ProbeApi | null } = { current: null }; render( - - (apiRef.current = api)} /> - , + + + (apiRef.current = api)} /> + + , ); await waitFor(() => expect(screen.getByTestId("count").textContent).toBe( diff --git a/frontend/editor/src/core/contexts/FolderContext.tsx b/frontend/editor/src/core/contexts/FolderContext.tsx index e218072ec..65b746bd6 100644 --- a/frontend/editor/src/core/contexts/FolderContext.tsx +++ b/frontend/editor/src/core/contexts/FolderContext.tsx @@ -38,6 +38,8 @@ import { } from "@app/types/folder"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/constants/routes"; interface FolderContextValue { folders: FolderRecord[]; @@ -363,10 +365,17 @@ export function FolderProvider({ children }: FolderProviderProps) { // guaranteed-to-403 round-trip per session. const { config: appConfig } = useAppConfig(); const storageBackedByServer = appConfig?.storageEnabled === true; + // Don't hit the authenticated storage API on auth routes (/login, /signup, + // /auth/*, ...). The global FolderProvider is mounted everywhere, including + // the login screen where the user has no session yet, so an unguarded pull + // fires a guaranteed-401 GET /api/v1/storage/folders before sign-in. Mirror + // the auth-route skip used by LicenseContext / AppConfigContext. + const location = useLocation(); + const onAuthRoute = isAuthRoute(location.pathname); useEffect(() => { - if (!storageBackedByServer) return; + if (!storageBackedByServer || onAuthRoute) return; void pullFromServer(); - }, [pullFromServer, storageBackedByServer]); + }, [pullFromServer, storageBackedByServer, onAuthRoute]); const foldersById = useMemo(() => { const map = new Map(); diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 3f51f7ba3..0df5fbee6 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -243,8 +243,13 @@ export default defineConfig(async ({ mode }) => { // SPA fallback returns index.html as text/html and React never mounts. // VITE_BUILD_FOR_PREVIEW=1 (set by the CI playwright steps) overrides to // an absolute base so deep-route asset paths resolve to /assets/... + // Trailing slash is required: it becomes `` in index.html, and + // the browser resolves relative links (manifest.json, modern-logo/favicon.ico) + // against the base's *directory*. Without it, `/bpp` resolves relatives to + // the domain root → `/manifest.json` 404 instead of `/bpp/manifest.json`. + // getBasePath() strips the trailing slash, so BASE_PATH/routing are unchanged. base: env.RUN_SUBPATH - ? `/${env.RUN_SUBPATH}` + ? `/${env.RUN_SUBPATH}/` : process.env.VITE_BUILD_FOR_PREVIEW === "1" ? "/" : "./", From 247ef6313cbc141c2aabc89ec1540ce3d69e021c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:13:20 +0100 Subject: [PATCH 18/80] fix: stop login loop caused by unguarded folder-sync 401 The deployed app looped /login -> / -> /login forever: Login sees a valid Supabase session and navigates to /, the global FolderProvider pulls GET /api/v1/storage/folders, the backend rejects it with 401, and the global error handler hard-redirects back to /login?from=/bpp. fileSyncService's /api/v1/storage/files pull already opts out via suppressErrorToast + skipAuthRedirect, so its 401 fails silently; folderSyncService.list() passed neither flag, so its 401 fell through to the redirect. Add the same flags - FolderContext.pullFromServer already handles 4xx locally (flips serverReachable, suppresses the banner). Note: the underlying 401 on /api/v1/storage/* with a valid session is a backend/deployment issue (storage endpoints rejecting the Supabase token); this change makes the frontend resilient so it degrades to "folder sync unavailable" instead of an auth loop. Co-Authored-By: Claude Opus 4.8 --- .../editor/src/core/services/folderSyncService.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/editor/src/core/services/folderSyncService.ts b/frontend/editor/src/core/services/folderSyncService.ts index 90af78270..7d863f0c2 100644 --- a/frontend/editor/src/core/services/folderSyncService.ts +++ b/frontend/editor/src/core/services/folderSyncService.ts @@ -62,8 +62,21 @@ function toFolderRecord(dto: ServerFolder): FolderRecord { export const folderSyncService = { async list(): Promise { + // Background sync fired automatically by the global FolderProvider on + // every (non-auth-route) load. If the backend rejects the request with a + // 401 - e.g. the storage API doesn't accept this deployment's session + // token - the error must NOT reach the global 401 handler: that handler + // hard-redirects to /login, the login page sees a valid session and + // bounces back to /, FolderProvider pulls again, and the app loops + // login->/->login forever. FolderContext.pullFromServer already handles + // 4xx locally (flips serverReachable, no banner), so fail silently here - + // mirroring fileSyncService's /api/v1/storage/files pull. const response = await apiClient.get( "/api/v1/storage/folders", + { + suppressErrorToast: true, + skipAuthRedirect: true, + }, ); return (response.data ?? []).map(toFolderRecord); }, From e7bbbb47029c181b4e919cc73db6fd5b873dd815 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:44:21 +0100 Subject: [PATCH 19/80] fix: make the 401 login redirect loop structurally impossible Audit of every code path that can produce the login->/->login cycle found the observed loop was one instance of a repeatable class: any automatic API call that persistently 401s while the Supabase session is valid triggers httpErrorHandler's hard redirect to /login, which sees the valid session and bounces back. Close the class, not just the instance: - saas apiClient: a 401 that survives a refresh-and-retry means the backend rejected a valid token (authz bug / wrong origin), not an expired session - never redirect to /login for it. Also fix the stale publicEndpoints entry ('endpoints-enabled' matched nothing; the real routes are endpoints-availability and endpoint-enabled). - httpErrorHandler: sessionStorage loop breaker - if a 401 redirect already fired within 10s, suppress the repeat instead of cycling. - Guard the remaining unflagged automatic callers: /api/v1/credits (fires on session init and TOKEN_REFRESHED), endpoints-availability (fires on app load), and ui-data/login (auto-called when a stale stirling_jwt is present). Co-Authored-By: Claude Opus 4.8 --- .../src/core/hooks/useEndpointConfig.ts | 10 ++++- .../src/core/services/accountService.ts | 4 ++ .../src/core/services/httpErrorHandler.ts | 41 +++++++++++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 9 +++- .../editor/src/saas/services/apiClient.ts | 19 ++++++++- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 9bd5f1712..307b4a460 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -128,10 +128,16 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { "[useEndpointConfig] Fetching all endpoint statuses from server", ); - // Fetch all endpoints at once - no query params needed + // Fetch all endpoints at once - no query params needed. + // Fires automatically on app load; a 401 here (e.g. backend rejecting + // an otherwise-valid session) must fail silently rather than reach the + // global handler, which hard-redirects to /login and can loop. const response = await apiClient.get< Record - >(`/api/v1/config/endpoints-availability`); + >(`/api/v1/config/endpoints-availability`, { + suppressErrorToast: true, + skipAuthRedirect: true, + }); // Populate global cache with all results Object.entries(response.data).forEach(([endpoint, details]) => { diff --git a/frontend/editor/src/core/services/accountService.ts b/frontend/editor/src/core/services/accountService.ts index 378a08423..97d4f4cad 100644 --- a/frontend/editor/src/core/services/accountService.ts +++ b/frontend/editor/src/core/services/accountService.ts @@ -28,8 +28,12 @@ export const accountService = { * This is a public endpoint - doesn't require authentication */ async getLoginPageData(): Promise { + // Public endpoint, but also auto-called by the onboarding orchestrator + // when a stale stirling_jwt is in localStorage — a 401 must never trigger + // the global login redirect (it would loop on every page load). const response = await apiClient.get( "/api/v1/proprietary/ui-data/login", + { suppressErrorToast: true, skipAuthRedirect: true }, ); return response.data; }, diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 968a44a99..b831f1581 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -46,6 +46,39 @@ function stashPostLoginRedirect(path: string): void { } } +// Loop breaker for the 401 hard-redirect below. If the backend persistently +// 401s an automatic call while the auth session is actually valid, the +// redirect lands on /login, the login page sees the valid session and bounces +// back, the call 401s again and we redirect again — forever. sessionStorage +// survives the full-page navigations of that cycle (per tab), so a second +// redirect within the window means we're looping, not expiring. +const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect"; +const LOGIN_REDIRECT_THROTTLE_MS = 10_000; + +function loginRedirectRecentlyFired(): boolean { + try { + const last = Number( + window.sessionStorage.getItem(LOGIN_REDIRECT_THROTTLE_KEY), + ); + return ( + Number.isFinite(last) && Date.now() - last < LOGIN_REDIRECT_THROTTLE_MS + ); + } catch { + return false; + } +} + +function markLoginRedirectFired(): void { + try { + window.sessionStorage.setItem( + LOGIN_REDIRECT_THROTTLE_KEY, + String(Date.now()), + ); + } catch { + // sessionStorage unavailable — fail open + } +} + /** * Handles HTTP errors with toast notifications and file error broadcasting * Returns true if the error should be suppressed (deduplicated), false otherwise @@ -71,6 +104,13 @@ export async function handleHttpError(error: any): Promise { // If not on auth page, redirect to login with expired session message if (!isAuthPage && !skipAuthRedirect) { + if (loginRedirectRecentlyFired()) { + console.warn( + "[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:", + error?.config?.url, + ); + return true; + } console.debug("[httpErrorHandler] 401 detected, redirecting to login"); // Spring 302-strips the ?from= query from /login, so stash the return // path in sessionStorage (AuthCallback reads it after SSO round-trip). @@ -83,6 +123,7 @@ export async function handleHttpError(error: any): Promise { // ignore storage access failures } const expiredPrefix = hadStoredJwt ? "expired=true&" : ""; + markLoginRedirectFired(); window.location.href = `${withBasePath("/login")}?${expiredPrefix}from=${encodeURIComponent(currentLocation)}`; return true; // Suppress toast since we're redirecting } diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 8f46a1833..c493df006 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -161,7 +161,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] Fetching credits for user:", currentSession.user.id, ); - const response = await apiClient.get("/api/v1/credits"); + // Fired automatically on session init and TOKEN_REFRESHED. If the + // backend rejects it (deploy skew, authz bug) the failure must stay + // local — the global 401 handler would hard-redirect to /login and, + // with a valid session, loop login -> / -> login forever. + const response = await apiClient.get("/api/v1/credits", { + suppressErrorToast: true, + skipAuthRedirect: true, + }); const apiCredits = response.data; // Map server payload to app CreditSummary diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index f484b28f8..c806d5217 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -116,7 +116,10 @@ const publicEndpoints = [ "/api/v1/config/app-config", "/api/v1/info/status", "/api/v1/config/public-config", - "/api/v1/config/endpoints-enabled", + // Both real endpoint-config routes (an earlier entry here said + // "endpoints-enabled", which matches neither and silently guarded nothing). + "/api/v1/config/endpoints-availability", + "/api/v1/config/endpoint-enabled", ]; // De-duplicate concurrent token refreshes. @@ -254,6 +257,20 @@ apiClient.interceptors.response.use( ); originalRequest.skipAuthRedirect = true; } + + // If a request was already retried with a freshly refreshed token and the + // backend STILL returned 401, the session is not the problem — the backend + // is rejecting a valid token (authorization bug, wrong API origin, etc.). + // Redirecting to /login cannot fix that: the login page sees the valid + // Supabase session and bounces straight back, producing an infinite + // login -> / -> login loop. Keep the error toast, skip the redirect. + if (status === 401 && originalRequest._retry && !isPublicEndpoint) { + console.warn( + "[API Client] 401 persisted after token refresh; backend rejected a valid session — not redirecting to login:", + originalRequest.url, + ); + originalRequest.skipAuthRedirect = true; + } const url = error.config?.url; const method = error.config?.method?.toUpperCase(); From d29059e6fb34c1ee049a1eb2555032ef67ba615d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:44:22 +0100 Subject: [PATCH 20/80] fix: storage APIs 401'd valid Supabase sessions (principal type mismatch) FileStorageService.requireAuthenticatedUser and FolderService.requireAuthenticatedUser authorize via 'principal instanceof User', but EnhancedJwtAuthenticationToken extends JwtAuthenticationToken whose principal is the decoded Jwt - so every /api/v1/storage/* request 401'd for JWT users AFTER Spring Security had already authenticated them. This persistent 401-with-valid-session was the trigger feeding the frontend login loop. Attach the filter-resolved local User as the token principal for full accounts (User implements UserDetails, matching the form-login convention every shared instanceof check expects). Anonymous sessions keep the raw Jwt principal, preserving their existing exclusions. All other principal consumers verified safe: AuthenticationUtils checks instanceof User first, extractSupabaseId/CreditController/Team SecurityExpressions switch on the authentication type, not the principal. Co-Authored-By: Claude Opus 4.8 --- .../EnhancedJwtAuthenticationToken.java | 26 +++++++++++++++++++ .../SupabaseAuthenticationFilter.java | 10 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java index c67845319..679e285f0 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java @@ -6,6 +6,8 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import stirling.software.proprietary.security.model.User; + /** * JWT auth token that exposes the Supabase subject UUID and email alongside the standard claims, so * downstream code (audit, credit accounting) can avoid re-parsing the JWT every request. @@ -14,15 +16,39 @@ public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken { private final String supabaseId; private final String email; + private final User user; public EnhancedJwtAuthenticationToken( Jwt jwt, Collection authorities, String email, String supabaseId) { + this(jwt, authorities, email, supabaseId, null); + } + + public EnhancedJwtAuthenticationToken( + Jwt jwt, + Collection authorities, + String email, + String supabaseId, + User user) { super(jwt, authorities, email); this.email = email; this.supabaseId = supabaseId; + this.user = user; + } + + /** + * Shared proprietary/core code authorizes with {@code principal instanceof User} (e.g. {@code + * FileStorageService.requireAuthenticatedUser}, {@code FolderService}), which the default + * {@link JwtAuthenticationToken} principal — the decoded {@link Jwt} — can never satisfy, so + * every such endpoint 401'd valid Supabase sessions. When the filter resolved a local {@link + * User}, expose it as the principal so those checks behave exactly as under form login; fall + * back to the Jwt when no User was attached (anonymous sessions, converter-built tokens). + */ + @Override + public Object getPrincipal() { + return user != null ? user : super.getPrincipal(); } public String getSupabaseId() { diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 08cdfcf57..888a5773f 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -155,9 +155,17 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { User user = getOrCreateUser(jwt); + // Attach the resolved User as the token principal for full accounts so shared + // `principal instanceof User` authorization (storage services, account data) + // works under JWT auth. Anonymous sessions keep the raw Jwt principal and stay + // locked out of those User-gated APIs, matching pre-existing behaviour. EnhancedJwtAuthenticationToken authToken = new EnhancedJwtAuthenticationToken( - jwt, user.getAuthorities(), user.getUsername(), supabaseId); + jwt, + user.getAuthorities(), + user.getUsername(), + supabaseId, + isAnonymous(jwt) ? null : user); SecurityContextHolder.getContext().setAuthentication(authToken); // Hot path: runs on every authenticated request (>10 per page on a typical SPA), From bf18af4708f90657e47a433e6cad875a23a4861c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:25:19 +0100 Subject: [PATCH 21/80] fix: show user avatar on the home page settings button The bottom-left settings button and the settings page both read profilePictureUrl, but only the settings page had a fallback (initials avatar) - the button silently fell back to a gear. The URL itself was usually null because fetchProfilePicture raced the background OAuth avatar sync with a fixed 500ms delay and never retried, and a missing bucket object simply resolved to null. - useConfigButtonIcon: fall back to the same initials avatar as the settings page instead of the gear when no picture URL is available. - UseSession: fetch the profile picture when syncOAuthAvatar settles (init and SIGNED_IN) instead of after an arbitrary 500ms. - fetchProfilePicture: when the bucket copy is missing, fall back to the OAuth provider's own photo URL so the picture shows immediately on first login - unless the user explicitly uploaded/removed a picture (metadata source 'upload'), preserving the remove flow. --- frontend/editor/src/saas/auth/UseSession.tsx | 67 +++++++++++++------ .../src/saas/hooks/useConfigButtonIcon.tsx | 14 +++- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index c493df006..0fd02bb5f 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -27,6 +27,7 @@ import { synchronizeUserUpgrade } from "@app/services/userService"; import { syncOAuthAvatar, getProfilePictureMetadata, + getProviderAvatarUrl, type ProfilePictureMetadata, } from "@app/services/avatarSyncService"; @@ -321,6 +322,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchTrialStatus(); }, [fetchTrialStatus]); + // Provider photo as interim fallback when the bucket copy is missing — + // skipped when the user explicitly chose upload/removal (source "upload"). + const providerAvatarFallback = useCallback( + async (user: SupabaseUser): Promise => { + try { + const metadata = await getProfilePictureMetadata(user.id); + if (metadata?.source === "upload") return null; + return getProviderAvatarUrl(user); + } catch { + return getProviderAvatarUrl(user); + } + }, + [], + ); + const fetchProfilePicture = useCallback( async (sessionToUse?: Session | null) => { const currentSession = sessionToUse ?? session; @@ -351,7 +367,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] Profile picture not available:", error.message, ); - setProfilePictureUrl(null); + setProfilePictureUrl( + await providerAvatarFallback(currentSession.user), + ); } else { setProfilePictureUrl(data.signedUrl); console.debug( @@ -360,10 +378,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } catch (error: unknown) { console.debug("[Auth Debug] Failed to fetch profile picture:", error); - setProfilePictureUrl(null); + setProfilePictureUrl(await providerAvatarFallback(currentSession.user)); } }, - [session], + [session, providerAvatarFallback], ); const refreshProfilePicture = useCallback(async () => { @@ -522,23 +540,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response if (data.session?.user) { - // Sync OAuth avatar in background - syncOAuthAvatar(data.session.user).catch((err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar on init:", - err, - ); - }); + // Sync OAuth avatar in background; fetch the picture once the + // sync settles instead of guessing with a fixed delay. + syncOAuthAvatar(data.session.user) + .catch((err) => { + console.debug( + "[Auth Debug] Failed to sync OAuth avatar on init:", + err, + ); + return false; + }) + .then(() => fetchProfilePicture(data.session)); await fetchCredits(data.session); await fetchProStatus(data.session); await fetchTrialStatus(data.session); await fetchProfilePictureMetadata(data.session); - - // Small delay to allow avatar sync to complete if quick - setTimeout(() => { - fetchProfilePicture(data.session); - }, 500); } } } catch (err) { @@ -602,9 +619,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { // null/loading states. // Sync OAuth avatar in background (don't block other fetches) - syncOAuthAvatar(newSession.user).catch((err) => { - console.debug("[Auth Debug] Failed to sync OAuth avatar:", err); - }); + const avatarSync = syncOAuthAvatar(newSession.user).catch( + (err) => { + console.debug( + "[Auth Debug] Failed to sync OAuth avatar:", + err, + ); + return false; + }, + ); // Fetch user data in parallel Promise.all([ @@ -613,15 +636,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { fetchTrialStatus(newSession), fetchProfilePictureMetadata(newSession), ]).then(() => { - // Fetch profile picture AFTER sync has had time to complete - // Use a small delay to allow avatar sync to finish if it's quick - setTimeout(() => { + // Fetch the picture once the avatar sync settles rather than + // after a fixed 500ms the first-ever sync usually loses. + avatarSync.then(() => { fetchProfilePicture(newSession).finally(() => { console.debug( "[Auth Debug] User data fully loaded after sign in", ); }); - }, 500); + }); }); } } else if (event === "TOKEN_REFRESHED") { diff --git a/frontend/editor/src/saas/hooks/useConfigButtonIcon.tsx b/frontend/editor/src/saas/hooks/useConfigButtonIcon.tsx index 904eeb088..3d8761a27 100644 --- a/frontend/editor/src/saas/hooks/useConfigButtonIcon.tsx +++ b/frontend/editor/src/saas/hooks/useConfigButtonIcon.tsx @@ -2,8 +2,16 @@ import { Avatar } from "@mantine/core"; import { useAuth } from "@app/auth/UseSession"; export function useConfigButtonIcon(): React.ReactNode { - const { profilePictureUrl } = useAuth(); - return profilePictureUrl ? ( - + const { profilePictureUrl, user } = useAuth(); + if (profilePictureUrl) { + return ; + } + // Mirror the settings page fallback (initials avatar) so both spots show + // the same identity badge while the picture URL is unavailable. + const initial = user?.email?.trim()?.charAt(0)?.toUpperCase(); + return initial ? ( + + {initial} + ) : null; } From 5b412c0fed8fb88508028c70a4b269cef03290f0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:30:29 +0100 Subject: [PATCH 22/80] cleanup: trim oversized comments across recent SaaS fixes Reduce multi-paragraph comment blocks to short two-line notes and drop history-style references; no behaviour changes. --- .../EnhancedJwtAuthenticationToken.java | 8 +--- .../SupabaseAuthenticationFilter.java | 6 +-- .../src/core/contexts/FolderContext.tsx | 7 +--- .../src/core/hooks/useEndpointConfig.ts | 6 +-- .../src/core/services/accountService.ts | 5 +-- .../src/core/services/folderSyncService.ts | 11 +----- .../src/core/services/httpErrorHandler.ts | 8 +--- frontend/editor/src/saas/auth/UseSession.tsx | 9 ++--- .../editor/src/saas/services/apiClient.ts | 38 ++++--------------- .../src/saas/services/supabaseClient.ts | 20 ++-------- frontend/editor/vite.config.ts | 7 +--- 11 files changed, 30 insertions(+), 95 deletions(-) diff --git a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java index 679e285f0..6991c2bfa 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java +++ b/app/saas/src/main/java/stirling/software/saas/security/EnhancedJwtAuthenticationToken.java @@ -39,12 +39,8 @@ public class EnhancedJwtAuthenticationToken extends JwtAuthenticationToken { } /** - * Shared proprietary/core code authorizes with {@code principal instanceof User} (e.g. {@code - * FileStorageService.requireAuthenticatedUser}, {@code FolderService}), which the default - * {@link JwtAuthenticationToken} principal — the decoded {@link Jwt} — can never satisfy, so - * every such endpoint 401'd valid Supabase sessions. When the filter resolved a local {@link - * User}, expose it as the principal so those checks behave exactly as under form login; fall - * back to the Jwt when no User was attached (anonymous sessions, converter-built tokens). + * Returns the resolved local {@link User} when available so shared {@code principal instanceof + * User} authorization works under JWT auth; falls back to the decoded Jwt. */ @Override public Object getPrincipal() { diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java index 888a5773f..5c97bd179 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseAuthenticationFilter.java @@ -155,10 +155,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter { User user = getOrCreateUser(jwt); - // Attach the resolved User as the token principal for full accounts so shared - // `principal instanceof User` authorization (storage services, account data) - // works under JWT auth. Anonymous sessions keep the raw Jwt principal and stay - // locked out of those User-gated APIs, matching pre-existing behaviour. + // Full accounts carry the resolved User as principal for shared + // instanceof-User authorization; anonymous sessions keep the raw Jwt. EnhancedJwtAuthenticationToken authToken = new EnhancedJwtAuthenticationToken( jwt, diff --git a/frontend/editor/src/core/contexts/FolderContext.tsx b/frontend/editor/src/core/contexts/FolderContext.tsx index 65b746bd6..81aba6a2b 100644 --- a/frontend/editor/src/core/contexts/FolderContext.tsx +++ b/frontend/editor/src/core/contexts/FolderContext.tsx @@ -365,11 +365,8 @@ export function FolderProvider({ children }: FolderProviderProps) { // guaranteed-to-403 round-trip per session. const { config: appConfig } = useAppConfig(); const storageBackedByServer = appConfig?.storageEnabled === true; - // Don't hit the authenticated storage API on auth routes (/login, /signup, - // /auth/*, ...). The global FolderProvider is mounted everywhere, including - // the login screen where the user has no session yet, so an unguarded pull - // fires a guaranteed-401 GET /api/v1/storage/folders before sign-in. Mirror - // the auth-route skip used by LicenseContext / AppConfigContext. + // Skip server pulls on auth routes: FolderProvider is mounted globally and + // /login has no session yet, so the pull would be a guaranteed 401. const location = useLocation(); const onAuthRoute = isAuthRoute(location.pathname); useEffect(() => { diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 307b4a460..461516478 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -128,10 +128,8 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { "[useEndpointConfig] Fetching all endpoint statuses from server", ); - // Fetch all endpoints at once - no query params needed. - // Fires automatically on app load; a 401 here (e.g. backend rejecting - // an otherwise-valid session) must fail silently rather than reach the - // global handler, which hard-redirects to /login and can loop. + // Fetch all endpoints at once; auto-fires on app load, so a 401 must + // fail silently instead of triggering the global login redirect. const response = await apiClient.get< Record >(`/api/v1/config/endpoints-availability`, { diff --git a/frontend/editor/src/core/services/accountService.ts b/frontend/editor/src/core/services/accountService.ts index 97d4f4cad..bc43cdd2d 100644 --- a/frontend/editor/src/core/services/accountService.ts +++ b/frontend/editor/src/core/services/accountService.ts @@ -28,9 +28,8 @@ export const accountService = { * This is a public endpoint - doesn't require authentication */ async getLoginPageData(): Promise { - // Public endpoint, but also auto-called by the onboarding orchestrator - // when a stale stirling_jwt is in localStorage — a 401 must never trigger - // the global login redirect (it would loop on every page load). + // Also auto-called by onboarding when a stale stirling_jwt exists; a 401 + // must never trigger the global login redirect. const response = await apiClient.get( "/api/v1/proprietary/ui-data/login", { suppressErrorToast: true, skipAuthRedirect: true }, diff --git a/frontend/editor/src/core/services/folderSyncService.ts b/frontend/editor/src/core/services/folderSyncService.ts index 7d863f0c2..53e1d7e65 100644 --- a/frontend/editor/src/core/services/folderSyncService.ts +++ b/frontend/editor/src/core/services/folderSyncService.ts @@ -62,15 +62,8 @@ function toFolderRecord(dto: ServerFolder): FolderRecord { export const folderSyncService = { async list(): Promise { - // Background sync fired automatically by the global FolderProvider on - // every (non-auth-route) load. If the backend rejects the request with a - // 401 - e.g. the storage API doesn't accept this deployment's session - // token - the error must NOT reach the global 401 handler: that handler - // hard-redirects to /login, the login page sees a valid session and - // bounces back to /, FolderProvider pulls again, and the app loops - // login->/->login forever. FolderContext.pullFromServer already handles - // 4xx locally (flips serverReachable, no banner), so fail silently here - - // mirroring fileSyncService's /api/v1/storage/files pull. + // Auto-fired by FolderProvider on every load; a persistent 401 must fail + // silently here or the global handler redirects to /login and loops. const response = await apiClient.get( "/api/v1/storage/folders", { diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index b831f1581..69d4c6e06 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -46,12 +46,8 @@ function stashPostLoginRedirect(path: string): void { } } -// Loop breaker for the 401 hard-redirect below. If the backend persistently -// 401s an automatic call while the auth session is actually valid, the -// redirect lands on /login, the login page sees the valid session and bounces -// back, the call 401s again and we redirect again — forever. sessionStorage -// survives the full-page navigations of that cycle (per tab), so a second -// redirect within the window means we're looping, not expiring. +// Loop breaker: a second 401 redirect within this window means the login page +// bounced us back with a live session — redirecting again would loop forever. const LOGIN_REDIRECT_THROTTLE_KEY = "stirling_last_401_redirect"; const LOGIN_REDIRECT_THROTTLE_MS = 10_000; diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 0fd02bb5f..94c1ae824 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -162,10 +162,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] Fetching credits for user:", currentSession.user.id, ); - // Fired automatically on session init and TOKEN_REFRESHED. If the - // backend rejects it (deploy skew, authz bug) the failure must stay - // local — the global 401 handler would hard-redirect to /login and, - // with a valid session, loop login -> / -> login forever. + // Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must + // stay local rather than trigger the global login redirect. const response = await apiClient.get("/api/v1/credits", { suppressErrorToast: true, skipAuthRedirect: true, @@ -636,8 +634,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { fetchTrialStatus(newSession), fetchProfilePictureMetadata(newSession), ]).then(() => { - // Fetch the picture once the avatar sync settles rather than - // after a fixed 500ms the first-ever sync usually loses. + // Fetch the picture once the avatar sync settles. avatarSync.then(() => { fetchProfilePicture(newSession).finally(() => { console.debug( diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index c806d5217..893bda76f 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -116,23 +116,12 @@ const publicEndpoints = [ "/api/v1/config/app-config", "/api/v1/info/status", "/api/v1/config/public-config", - // Both real endpoint-config routes (an earlier entry here said - // "endpoints-enabled", which matches neither and silently guarded nothing). "/api/v1/config/endpoints-availability", "/api/v1/config/endpoint-enabled", ]; -// De-duplicate concurrent token refreshes. -// -// On a cold load with an expired access token, many bootstrap requests -// (config, credits, footer-info, storage, ...) 401 at roughly the same -// instant. If each one calls supabase.auth.refreshSession() independently, -// Supabase rotates the refresh token on first use and the remaining -// concurrent refreshes fail with "Invalid Refresh Token: Already Used". -// That spurious failure then bounced the whole app to /login even though the -// session was perfectly recoverable. Sharing a single in-flight refresh -// promise makes the first 401 do the network refresh and everyone else awaits -// the same result. +// Share one in-flight refresh: Supabase rotates the refresh token on first +// use, so concurrent refreshSession() calls fail with "Already Used". let inFlightRefresh: ReturnType | null = null; function refreshSessionOnce(): ReturnType { @@ -185,12 +174,8 @@ apiClient.interceptors.response.use( originalRequest.url?.includes(endpoint), ); - // On a 401 (that we haven't already retried), try to recover the session - // before giving up. We attempt this for BOTH protected and public - // endpoints: the backend rejects any expired Bearer token regardless of - // route, so a "public" bootstrap call (e.g. /api/v1/config/app-config) can - // 401 on cold load too. Refreshing + retrying lets those succeed instead of - // tripping the global login redirect. + // On a first 401, refresh and retry — public endpoints included, since an + // expired Bearer token is rejected on any route during cold load. if (status === 401 && !originalRequest._retry) { originalRequest._retry = true; @@ -245,11 +230,8 @@ apiClient.interceptors.response.use( } } - // A 401 on a public endpoint must never trigger the global login redirect. - // If we reach here it means refresh/retry didn't resolve it (e.g. no - // session yet, or it 401'd again); suppress the redirect in the shared - // error handler so a transient bootstrap 401 can't bounce the app to - // /login while Supabase is still restoring the session. + // Public-endpoint 401s must never trigger the global login redirect + // (e.g. transient 401s while Supabase is still restoring the session). if (status === 401 && isPublicEndpoint) { console.debug( "[API Client] 401 on public endpoint, continuing without auth:", @@ -258,12 +240,8 @@ apiClient.interceptors.response.use( originalRequest.skipAuthRedirect = true; } - // If a request was already retried with a freshly refreshed token and the - // backend STILL returned 401, the session is not the problem — the backend - // is rejecting a valid token (authorization bug, wrong API origin, etc.). - // Redirecting to /login cannot fix that: the login page sees the valid - // Supabase session and bounces straight back, producing an infinite - // login -> / -> login loop. Keep the error toast, skip the redirect. + // A 401 that survived refresh-and-retry means the backend rejected a + // valid token; redirecting to /login would only bounce back and loop. if (status === 401 && originalRequest._retry && !isPublicEndpoint) { console.warn( "[API Client] 401 persisted after token refresh; backend rejected a valid session — not redirecting to login:", diff --git a/frontend/editor/src/saas/services/supabaseClient.ts b/frontend/editor/src/saas/services/supabaseClient.ts index e3a91558f..3dd248c5a 100644 --- a/frontend/editor/src/saas/services/supabaseClient.ts +++ b/frontend/editor/src/saas/services/supabaseClient.ts @@ -1,22 +1,8 @@ -// SaaS-layer override for `@app/services/supabaseClient`. -// -// There must be exactly ONE Supabase client (one GoTrueClient) per browser -// context. The :proprietary version of this module calls createClient() a -// second time with the same auth-token storage key as :saas's -// `@app/auth/supabase`. In the SaaS bundle, billing/licensing/user-management -// code (CheckoutContext, licenseService, AdminPlanSection, userManagementService) -// imports `@app/services/supabaseClient` and would otherwise pull in that second -// client. Two clients => two independent autoRefreshToken timers racing on the -// same refresh token => "Multiple GoTrueClient instances detected", rotated / -// "Already Used" refresh tokens, and spurious 401s (e.g. /api/v1/storage/*). -// -// Re-exporting the singleton from `@app/auth/supabase` keeps every -// `@app/services/supabaseClient` consumer pointed at the same instance, so the -// :proprietary module's createClient() is never bundled in the SaaS build. +// SaaS override of `@app/services/supabaseClient`: re-export the auth +// singleton so exactly one GoTrueClient (one refresh timer) exists per tab. import type { SupabaseClient } from "@supabase/supabase-js"; import { supabase as supabaseSingleton } from "@app/auth/supabase"; -// `@app/auth/supabase` throws on missing config, so in the SaaS build the -// client is always present and Supabase is always configured. +// `@app/auth/supabase` throws on missing config, so the client always exists. export const supabase: SupabaseClient | null = supabaseSingleton; export const isSupabaseConfigured = true; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 0df5fbee6..76416d57c 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -243,11 +243,8 @@ export default defineConfig(async ({ mode }) => { // SPA fallback returns index.html as text/html and React never mounts. // VITE_BUILD_FOR_PREVIEW=1 (set by the CI playwright steps) overrides to // an absolute base so deep-route asset paths resolve to /assets/... - // Trailing slash is required: it becomes `` in index.html, and - // the browser resolves relative links (manifest.json, modern-logo/favicon.ico) - // against the base's *directory*. Without it, `/bpp` resolves relatives to - // the domain root → `/manifest.json` 404 instead of `/bpp/manifest.json`. - // getBasePath() strips the trailing slash, so BASE_PATH/routing are unchanged. + // Trailing slash required: it becomes ``, and browsers resolve + // relative URLs (manifest.json, favicon) against the base's *directory*. base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}/` : process.env.VITE_BUILD_FOR_PREVIEW === "1" From 90bda6b4b4c60c0f1d7a73b96c6131650b25aa00 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:37:12 +0100 Subject: [PATCH 23/80] fix: User principal was discarded by the resource server re-authentication The saas chain authenticates bearer requests twice: SupabaseAuthenticationFilter builds an EnhancedJwtAuthenticationToken with the resolved User principal, but BearerTokenAuthenticationFilter (oauth2ResourceServer) then re-authenticates the same token through the static toAuthentication converter and overwrites the SecurityContext with a token whose principal is the raw Jwt - so storage endpoints kept returning 401 "Unsupported user principal" despite the principal fix. Carry the User across in the converter: when the context already holds an EnhancedJwtAuthenticationToken for the same subject with a User principal, attach that User to the converter-built token. No extra DB lookups; anonymous sessions and API-key auth unchanged. Covered by new unit tests (carry, no-context, subject mismatch). --- .../saas/security/SupabaseSecurityConfig.java | 15 +++++- .../security/SupabaseSecurityConfigTest.java | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index feaa83a39..2c44a15c9 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -23,8 +23,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; @@ -44,6 +46,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.RequestUriUtils; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.service.CreditService; @@ -333,6 +336,16 @@ public class SupabaseSecurityConfig { .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(","))); } - return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId); + // BearerTokenAuthenticationFilter overwrites the context SupabaseAuthenticationFilter + // built; carry its resolved User across so instanceof-User authorization keeps working. + User user = null; + Authentication existing = SecurityContextHolder.getContext().getAuthentication(); + if (existing instanceof EnhancedJwtAuthenticationToken enhanced + && supabaseId != null + && supabaseId.equals(enhanced.getSupabaseId()) + && enhanced.getPrincipal() instanceof User existingUser) { + user = existingUser; + } + return new EnhancedJwtAuthenticationToken(jwt, authorities, email, supabaseId, user); } } diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java index 5c21b88a2..49779fce8 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigTest.java @@ -9,17 +9,25 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; +import stirling.software.proprietary.security.model.User; import stirling.software.saas.security.SupabaseSecurityConfig.SupabaseTokenValidator; /** Unit tests for the JWT-claim → Spring authorities mapping. */ class SupabaseSecurityConfigTest { + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + @Test void anonymousJwtGetsLimitedApiUserRole() { Jwt jwt = jwtWith(true, null, null, null, List.of()); @@ -75,6 +83,47 @@ class SupabaseSecurityConfigTest { .doesNotContain("ROLE_", "PERM_"); } + @Test + void carriesUserPrincipalFromContextBuiltByAuthFilter() { + Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); + User user = new User(); + SecurityContextHolder.getContext() + .setAuthentication( + new EnhancedJwtAuthenticationToken( + jwt, List.of(), "alice@example.com", jwt.getSubject(), user)); + + AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); + + assertThat(auth.getPrincipal()).isSameAs(user); + } + + @Test + void principalStaysJwtWithoutContextUser() { + Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); + + AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); + + assertThat(auth.getPrincipal()).isSameAs(jwt); + } + + @Test + void ignoresContextUserForDifferentSubject() { + Jwt jwt = jwtWith(false, "alice@example.com", "authenticated", null, List.of()); + Jwt other = jwtWith(false, "bob@example.com", "authenticated", null, List.of()); + SecurityContextHolder.getContext() + .setAuthentication( + new EnhancedJwtAuthenticationToken( + other, + List.of(), + "bob@example.com", + other.getSubject(), + new User())); + + AbstractAuthenticationToken auth = SupabaseSecurityConfig.toAuthentication(jwt); + + assertThat(auth.getPrincipal()).isSameAs(jwt); + } + private static Jwt jwtWith( boolean anonymous, String email, From be0db3fd8ac9703c34ec8dbf23bcd7da564aba2d Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:49:16 +0100 Subject: [PATCH 24/80] fix: show profile picture in the FileSidebar bottom bar The home page's bottom-left settings button is FileSidebar's bottom bar, which hardcoded an initials circle - the avatar work in useConfigButtonIcon only affects the QuickAccessBar rail, which the home page doesn't render. Add a layered useProfilePictureUrl hook (core stub returns null; saas returns the auth context URL) and render the picture inside the existing avatar circle, falling back to the initial when absent or on image load failure. --- .../src/core/components/shared/FileSidebar.css | 8 ++++++++ .../src/core/components/shared/FileSidebar.tsx | 17 ++++++++++++++++- .../src/core/hooks/useProfilePictureUrl.ts | 6 ++++++ .../src/saas/hooks/useProfilePictureUrl.ts | 5 +++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 frontend/editor/src/core/hooks/useProfilePictureUrl.ts create mode 100644 frontend/editor/src/saas/hooks/useProfilePictureUrl.ts diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 875fcd9e7..fce21b32c 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -396,6 +396,14 @@ justify-content: center; flex-shrink: 0; user-select: none; + overflow: hidden; +} + +.file-sidebar-bottom-avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; } .file-sidebar-bottom-name { diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index c11ff76e8..2d6dc79d2 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -20,6 +20,7 @@ import { import { useViewer } from "@app/contexts/ViewerContext"; import { useFileHandler } from "@app/hooks/useFileHandler"; import { useAuth } from "@app/auth/UseSession"; +import { useProfilePictureUrl } from "@app/hooks/useProfilePictureUrl"; import { useIndexedDB, useIndexedDBRevision, @@ -184,6 +185,11 @@ const FileSidebar = forwardRef( const displayName = authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User"); + const profilePictureUrl = useProfilePictureUrl(); + const [pictureFailed, setPictureFailed] = useState(false); + useEffect(() => setPictureFailed(false), [profilePictureUrl]); + const showProfilePicture = !!profilePictureUrl && !pictureFailed; + useEffect(() => { if (!config?.enableLogin) { setAccountUsername(null); @@ -941,7 +947,16 @@ const FileSidebar = forwardRef( className="file-sidebar-bottom-avatar" aria-label={displayName} > - {displayName.charAt(0).toUpperCase()} + {showProfilePicture ? ( + setPictureFailed(true)} + /> + ) : ( + displayName.charAt(0).toUpperCase() + )} {!collapsed && ( diff --git a/frontend/editor/src/core/hooks/useProfilePictureUrl.ts b/frontend/editor/src/core/hooks/useProfilePictureUrl.ts new file mode 100644 index 000000000..b43e673c9 --- /dev/null +++ b/frontend/editor/src/core/hooks/useProfilePictureUrl.ts @@ -0,0 +1,6 @@ +/** + * Core stub — no profile picture source; auth-aware layers override this. + */ +export function useProfilePictureUrl(): string | null { + return null; +} diff --git a/frontend/editor/src/saas/hooks/useProfilePictureUrl.ts b/frontend/editor/src/saas/hooks/useProfilePictureUrl.ts new file mode 100644 index 000000000..3d2b4c635 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useProfilePictureUrl.ts @@ -0,0 +1,5 @@ +import { useAuth } from "@app/auth/UseSession"; + +export function useProfilePictureUrl(): string | null { + return useAuth().profilePictureUrl; +} From d6306f51e114bb07d946f6d07331b5b940d0cd6a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:04:12 +0100 Subject: [PATCH 25/80] fix: no blue disc behind the sidebar profile picture Keep the colored background only for the initials fallback; a real photo fills the circle with a transparent backing. --- frontend/editor/src/core/components/shared/FileSidebar.css | 5 +++++ frontend/editor/src/core/components/shared/FileSidebar.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index fce21b32c..3ae429742 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -399,6 +399,11 @@ overflow: hidden; } +/* No colored disc behind an actual photo; keep it for the initials fallback. */ +.file-sidebar-bottom-avatar--picture { + background-color: transparent; +} + .file-sidebar-bottom-avatar-img { width: 100%; height: 100%; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 2d6dc79d2..fb02ebf09 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -944,7 +944,9 @@ const FileSidebar = forwardRef( style={onOpenSettings ? { cursor: "pointer" } : undefined} >
{showProfilePicture ? ( From ebc28b0a14a67fa21ed95ce2ebbbaadcc5633dbc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 10 Jun 2026 15:54:18 +0100 Subject: [PATCH 26/80] Add team settings to SaaS (#6601) # Description of Changes Add team settings UI to SaaS, which is currently only available in desktop. It'd be nice to refactor this so they're more shared, but they're slightly different so needs to be done with some care. Leaving for followup work. --- .../public/locales/en-GB/translation.toml | 1 + .../src/saas/components/AppProviders.tsx | 18 + .../config/configSections/TeamSection.tsx | 704 ++++++++++++++++++ .../shared/config/saasConfigNavSections.tsx | 10 + .../src/saas/contexts/SaaSTeamContext.tsx | 299 ++++++++ 5 files changed, 1032 insertions(+) create mode 100644 frontend/editor/src/saas/components/AppProviders.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx create mode 100644 frontend/editor/src/saas/contexts/SaaSTeamContext.tsx diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 4310a4b7a..388933960 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2960,6 +2960,7 @@ tags = "squish,small,tiny" [config] plan = "Plan" +team = "Team" [config.account.overview] confirmDelete = "Delete My Account" diff --git a/frontend/editor/src/saas/components/AppProviders.tsx b/frontend/editor/src/saas/components/AppProviders.tsx new file mode 100644 index 000000000..ea9918b27 --- /dev/null +++ b/frontend/editor/src/saas/components/AppProviders.tsx @@ -0,0 +1,18 @@ +import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders"; +import { AppProvidersProps } from "@core/components/AppProviders"; +import { SaaSTeamProvider } from "@app/contexts/SaaSTeamContext"; + +export function AppProviders({ + children, + appConfigRetryOptions, + appConfigProviderProps, +}: AppProvidersProps) { + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx new file mode 100644 index 000000000..22e20ff77 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx @@ -0,0 +1,704 @@ +import React, { useState, useEffect } from "react"; +import { + Button, + TextInput, + Group, + Text, + Stack, + Alert, + Table, + Badge, + ActionIcon, + Menu, + List, + ThemeIcon, + Modal, + CloseButton, + Anchor, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; +import { useAuth } from "@app/auth/UseSession"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; +import apiClient from "@app/services/apiClient"; + +const TeamSection: React.FC = () => { + const { t } = useTranslation(); + const { + currentTeam, + teamMembers, + teamInvitations, + isTeamLeader, + isPersonalTeam, + inviteUser, + cancelInvitation, + removeMember, + leaveTeam, + refreshTeams, + } = useSaaSTeam(); + + const { isPro } = useAuth(); + + const [inviteEmail, setInviteEmail] = useState(""); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [featuresModalOpened, setFeaturesModalOpened] = useState(false); + + // Team rename state + const [isEditingName, setIsEditingName] = useState(false); + const [newTeamName, setNewTeamName] = useState(""); + const [renamingTeam, setRenamingTeam] = useState(false); + + // Refresh team data on mount and every 10 seconds + useEffect(() => { + // Refresh immediately on mount + refreshTeams(); + + // Then refresh every 10 seconds + const interval = setInterval(() => { + refreshTeams(); + }, 10000); + + return () => clearInterval(interval); + }, []); // Only run on mount/unmount + + const navigateToPlan = () => { + window.dispatchEvent( + new CustomEvent("appConfig:navigate", { detail: { key: "plan" } }), + ); + }; + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!inviteEmail.trim()) return; + + setInviting(true); + setError(null); + setSuccess(null); + + try { + await inviteUser(inviteEmail); + setSuccess( + t("team.inviteSent", "Invitation sent to {{email}}", { + email: inviteEmail, + }), + ); + setInviteEmail(""); + } catch (err) { + const error = err as { response?: { data?: { error?: string } } }; + setError( + error.response?.data?.error || + t("team.inviteError", "Failed to send invitation"), + ); + } finally { + setInviting(false); + } + }; + + const handleRemove = async (memberId: number, memberEmail: string) => { + if ( + !window.confirm( + t("team.confirmRemove", "Remove {{email}} from the team?", { + email: memberEmail, + }), + ) + ) + return; + + try { + await removeMember(memberId); + setSuccess(t("team.memberRemoved", "Member removed successfully")); + } catch (err) { + const error = err as { response?: { data?: { error?: string } } }; + setError( + error.response?.data?.error || + t("team.removeError", "Failed to remove member"), + ); + } + }; + + const handleCancelInvitation = async ( + invitationId: number, + email: string, + ) => { + if ( + !window.confirm( + t("team.confirmCancelInvite", "Cancel invitation for {{email}}?", { + email, + }), + ) + ) + return; + + try { + await cancelInvitation(invitationId); + setSuccess( + t("team.inviteCancelled", "Invitation for {{email}} cancelled", { + email, + }), + ); + } catch (err) { + const error = err as { response?: { data?: { error?: string } } }; + setError( + error.response?.data?.error || + t("team.cancelInviteError", "Failed to cancel invitation"), + ); + } + }; + + const handleStartRename = () => { + if (currentTeam) { + setNewTeamName(currentTeam.name); + setIsEditingName(true); + } + }; + + const handleCancelRename = () => { + setIsEditingName(false); + setNewTeamName(""); + }; + + const handleRenameSubmit = async () => { + if (!currentTeam || !newTeamName.trim()) return; + + setRenamingTeam(true); + setError(null); + + try { + await apiClient.post(`/api/v1/team/${currentTeam.teamId}/rename`, { + newName: newTeamName.trim(), + }); + + setSuccess(t("team.renameSuccess", "Team renamed successfully")); + setIsEditingName(false); + await refreshTeams(); + } catch (err) { + const error = err as { + response?: { data?: { error?: string } }; + message?: string; + }; + setError( + error.response?.data?.error || + error.message || + t("team.renameError", "Failed to rename team"), + ); + } finally { + setRenamingTeam(false); + } + }; + + const handleLeaveTeam = async () => { + if (!currentTeam || isPersonalTeam) return; + + const confirmMessage = isTeamLeader + ? t( + "team.confirmLeaveLeader", + 'Are you sure you want to leave "{{name}}"? You are a team leader. Make sure there are other leaders before leaving.', + { name: currentTeam.name }, + ) + : t("team.confirmLeave", 'Are you sure you want to leave "{{name}}"?', { + name: currentTeam.name, + }); + + if (!window.confirm(confirmMessage)) return; + + try { + await leaveTeam(); + setSuccess(t("team.leaveSuccess", "Successfully left team")); + } catch (err) { + const error = err as { + response?: { data?: { error?: string } }; + message?: string; + }; + setError( + error.response?.data?.error || + error.message || + t("team.leaveError", "Failed to leave team"), + ); + } + }; + + if (!currentTeam) { + return ( + + {t("team.loading", "Loading team information...")} + + ); + } + + return ( + + {/* Header */} +
+ +
+ {isEditingName ? ( + + setNewTeamName(e.target.value)} + placeholder={t("team.namePlaceholder", "Team name")} + style={{ flex: 1, maxWidth: 300 }} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleRenameSubmit(); + if (e.key === "Escape") handleCancelRename(); + }} + /> + + + + + + + + ) : ( + + + {currentTeam.name} + + {isTeamLeader && !isPersonalTeam && ( + + + + )} + {isTeamLeader && ( + {t("team.leader", "LEADER")} + )} + {isPersonalTeam && ( + + {t("team.personal", "Personal")} + + )} + + )} + {!isEditingName && !isPersonalTeam && ( + + {t("team.memberCount", "{{count}} team members", { + count: currentTeam.seatsUsed, + })} + + )} +
+ {!isPersonalTeam && !isTeamLeader && !isEditingName && ( + + )} +
+
+ + {/* Upgrade Banner for Free Users */} + {isPersonalTeam && !isPro && ( + } + > + +
+ + {t( + "team.upgrade.title", + "Upgrade to Pro to unlock team features", + )} + + + {t( + "team.upgrade.description", + "Invite members, share credits, and more.", + )}{" "} + setFeaturesModalOpened(true)} + style={{ cursor: "pointer" }} + > + {t("common.learnMore", "Learn more")} + + +
+ +
+
+ )} + + {/* Team Features Modal */} + setFeaturesModalOpened(false)} + size="md" + centered + padding="xl" + withCloseButton={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > +
+ setFeaturesModalOpened(false)} + size="lg" + style={{ + position: "absolute", + top: -8, + right: -8, + zIndex: 1, + }} + /> + + {/* Header */} + + + {t("team.features.badge", "PRO FEATURE")} + + + {t("team.features.title", "Team Collaboration")} + + + {t( + "team.features.subtitle", + "Upgrade to Pro and unlock powerful team features", + )} + + + + {/* Features List */} + + + + } + > + + + {t("team.features.invite.title", "Invite team members")} + + + {t( + "team.features.invite.description", + "Add unlimited users with additional seat purchases", + )} + + + + + {t( + "team.features.credits.title", + "Share credits across your team", + )} + + + {t( + "team.features.credits.description", + "Pool resources for collaborative work", + )} + + + + + {t( + "team.features.dashboard.title", + "Team management dashboard", + )} + + + {t( + "team.features.dashboard.description", + "Control permissions, monitor usage, and manage members", + )} + + + + + {t("team.features.billing.title", "Centralized billing")} + + + {t( + "team.features.billing.description", + "One invoice for all team seats and usage", + )} + + + + + {/* CTA Button */} + + +
+
+ + {/* Error/Success Messages */} + {error && ( + setError(null)} withCloseButton> + {error} + + )} + + {success && ( + setSuccess(null)} withCloseButton> + {success} + + )} + + {/* Invite Members (Pro Users) */} + {isTeamLeader && isPro && ( +
+ + {t("team.invite.title", "Invite Team Member")} + +
+ + setInviteEmail(e.target.value)} + style={{ flex: 1 }} + required + error={ + inviteEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail) + ? t("team.invite.invalidEmail", "Invalid email format") + : undefined + } + /> + + +
+
+ )} + + {/* Team Members Table */} +
+ + {t("team.members.title", "Team Members")} + + + + + + {t("team.members.nameColumn", "Name")} + + + {t("team.members.emailColumn", "Email")} + + + {t("team.members.roleColumn", "Role")} + + {isTeamLeader && !isPersonalTeam && ( + + )} + + + + {teamMembers.length === 0 && teamInvitations.length === 0 ? ( + + + + {t("team.members.empty", "No team members yet.")} + + + + ) : ( + <> + {/* Active Members */} + {teamMembers.map((member) => ( + + + + {member.username} + + + + + {member.email} + + + + + {member.role} + + + {isTeamLeader && !isPersonalTeam && ( + + {member.role !== "LEADER" && ( + + + + + + + + + } + onClick={() => + handleRemove(member.id, member.email) + } + > + {t("team.members.remove", "Remove from Team")} + + + + )} + + )} + + ))} + + {/* Pending Invitations */} + {teamInvitations + .filter((inv) => inv.status === "PENDING") + .map((invitation) => ( + + + + {invitation.inviteeEmail.split("@")[0]} + + + + + {invitation.inviteeEmail} + + + + + {t("team.members.pending", "PENDING")} + + + {isTeamLeader && !isPersonalTeam && ( + + + handleCancelInvitation( + invitation.invitationId, + invitation.inviteeEmail, + ) + } + aria-label={t( + "team.invite.cancelLabel", + "Cancel invitation", + )} + > + + + + )} + + ))} + + )} + +
+
+
+ ); +}; + +export default TeamSection; diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 27979ff5a..9ec28f42e 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -10,6 +10,7 @@ import PasswordSecurity from "@app/components/shared/config/configSections/Passw import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import Plan from "@app/components/shared/config/configSections/Plan"; import McpSection from "@app/components/shared/config/configSections/McpSection"; +import TeamSection from "@app/components/shared/config/configSections/TeamSection"; type OverviewComponent = React.ComponentType<{ onLogoutClick: () => void }>; @@ -173,6 +174,15 @@ export function createSaasConfigNavSections( ], }; + if (!isAnonymous) { + accountSection.items.push({ + key: "teams", + label: t("config.team", "Team"), + icon: "groups-rounded", + component: , + }); + } + let sections = [accountSection, ...baseSections]; // Suppress OSS-only sections (update checker, login config banner) not relevant in SaaS diff --git a/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx b/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx new file mode 100644 index 000000000..18df1ed0d --- /dev/null +++ b/frontend/editor/src/saas/contexts/SaaSTeamContext.tsx @@ -0,0 +1,299 @@ +import { + createContext, + useContext, + useEffect, + useState, + ReactNode, + useCallback, +} from "react"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; +import { isUserAnonymous } from "@app/auth/supabase"; + +/** + * SaaS web implementation of SaaS Team Context + * Provides team management for authenticated (non-anonymous) users + */ + +interface Team { + teamId: number; + name: string; + teamType: string; + isPersonal: boolean; + memberCount: number; + seatCount: number; + seatsUsed: number; + maxSeats: number; + isLeader: boolean; +} + +interface TeamMember { + id: number; + username: string; + email: string; + role: string; + joinedAt: string; +} + +interface TeamInvitation { + invitationId: number; + teamName: string; + inviterEmail: string; + inviteeEmail: string; + invitationToken: string; + status: string; + expiresAt: string; +} + +interface SaaSTeamContextType { + currentTeam: Team | null; + teams: Team[]; + teamMembers: TeamMember[]; + teamInvitations: TeamInvitation[]; + receivedInvitations: TeamInvitation[]; + isTeamLeader: boolean; + isPersonalTeam: boolean; + loading: boolean; + + inviteUser: (email: string) => Promise; + acceptInvitation: (token: string) => Promise; + rejectInvitation: (token: string) => Promise; + cancelInvitation: (invitationId: number) => Promise; + removeMember: (memberId: number) => Promise; + leaveTeam: () => Promise; + refreshTeams: () => Promise; +} + +const SaaSTeamContext = createContext({ + currentTeam: null, + teams: [], + teamMembers: [], + teamInvitations: [], + receivedInvitations: [], + isTeamLeader: false, + isPersonalTeam: true, + loading: true, + inviteUser: async () => {}, + acceptInvitation: async () => {}, + rejectInvitation: async () => {}, + cancelInvitation: async () => {}, + removeMember: async () => {}, + leaveTeam: async () => {}, + refreshTeams: async () => {}, +}); + +export function SaaSTeamProvider({ children }: { children: ReactNode }) { + const [currentTeam, setCurrentTeam] = useState(null); + const [teams, setTeams] = useState([]); + const [teamMembers, setTeamMembers] = useState([]); + const [teamInvitations, setTeamInvitations] = useState([]); + const [receivedInvitations, setReceivedInvitations] = useState< + TeamInvitation[] + >([]); + const [loading, setLoading] = useState(true); + + const { user, refreshCredits, refreshSession } = useAuth(); + const canUseTeams = !!user && !isUserAnonymous(user); + + const fetchMyTeams = useCallback(async () => { + if (!canUseTeams) return null; + + try { + const response = await apiClient.get("/api/v1/team/my", { + suppressErrorToast: true, + }); + setTeams(response.data); + + const activeTeam = response.data[0]; + setCurrentTeam(activeTeam || null); + return activeTeam || null; + } catch (error) { + console.error("[SaaSTeamContext] Failed to fetch teams:", error); + return null; + } + }, [canUseTeams]); + + const fetchTeamMembers = useCallback(async (teamId: number) => { + try { + const response = await apiClient.get( + `/api/v1/team/${teamId}/members`, + { suppressErrorToast: true }, + ); + setTeamMembers(response.data); + } catch (error) { + console.error("[SaaSTeamContext] Failed to fetch team members:", error); + } + }, []); + + const fetchTeamInvitations = useCallback( + async (teamId?: number) => { + if (!canUseTeams || !teamId) return; + + try { + const response = await apiClient.get( + `/api/v1/team/${teamId}/invitations`, + { suppressErrorToast: true }, + ); + setTeamInvitations(response.data); + } catch (error) { + console.error( + "[SaaSTeamContext] Failed to fetch team invitations:", + error, + ); + } + }, + [canUseTeams], + ); + + const fetchReceivedInvitations = useCallback(async () => { + if (!canUseTeams) return; + + try { + const response = await apiClient.get( + "/api/v1/team/invitations/pending", + { suppressErrorToast: true }, + ); + setReceivedInvitations(response.data); + } catch (error) { + console.error( + "[SaaSTeamContext] Failed to fetch received invitations:", + error, + ); + } + }, [canUseTeams]); + + useEffect(() => { + if (canUseTeams) { + fetchMyTeams(); + fetchReceivedInvitations(); + } else { + setTeams([]); + setCurrentTeam(null); + setTeamMembers([]); + setTeamInvitations([]); + setReceivedInvitations([]); + setLoading(false); + } + }, [canUseTeams, fetchMyTeams, fetchReceivedInvitations]); + + useEffect(() => { + if (currentTeam && !currentTeam.isPersonal) { + fetchTeamMembers(currentTeam.teamId); + // Only fetch invitations if user is team leader + if (currentTeam.isLeader) { + fetchTeamInvitations(currentTeam.teamId); + } else { + setTeamInvitations([]); + } + } else { + setTeamMembers([]); + setTeamInvitations([]); + } + setLoading(false); + }, [currentTeam, fetchTeamMembers, fetchTeamInvitations]); + + const inviteUser = async (email: string) => { + if (!currentTeam) throw new Error("No current team"); + + await apiClient.post("/api/v1/team/invite", { + teamId: currentTeam.teamId, + email, + }); + await fetchTeamInvitations(currentTeam.teamId); + }; + + const refreshTeams = useCallback(async () => { + const newCurrentTeam = await fetchMyTeams(); + await fetchReceivedInvitations(); + if (newCurrentTeam && !newCurrentTeam.isPersonal) { + await fetchTeamMembers(newCurrentTeam.teamId); + // Only fetch invitations if user is team leader + if (newCurrentTeam.isLeader) { + await fetchTeamInvitations(newCurrentTeam.teamId); + } + } + }, [ + fetchMyTeams, + fetchReceivedInvitations, + fetchTeamMembers, + fetchTeamInvitations, + ]); + + const acceptInvitation = async (token: string) => { + await apiClient.post(`/api/v1/team/invitations/${token}/accept`); + await fetchReceivedInvitations(); + await refreshTeams(); + await refreshCredits(); + await refreshSession(); + }; + + const rejectInvitation = async (token: string) => { + await apiClient.post(`/api/v1/team/invitations/${token}/reject`); + await fetchReceivedInvitations(); + }; + + const cancelInvitation = async (invitationId: number) => { + await apiClient.delete(`/api/v1/team/invitations/${invitationId}`); + if (currentTeam) { + await fetchTeamInvitations(currentTeam.teamId); + } + }; + + const removeMember = async (memberId: number) => { + if (!currentTeam) throw new Error("No current team"); + + await apiClient.delete( + `/api/v1/team/${currentTeam.teamId}/members/${memberId}`, + ); + await refreshTeams(); + await fetchTeamMembers(currentTeam.teamId); + // No need to refresh session/credits: the team leader's status hasn't changed + }; + + const leaveTeam = async () => { + if (!currentTeam) throw new Error("No current team"); + + await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`); + await refreshTeams(); + await refreshCredits(); + await refreshSession(); + }; + + const isTeamLeader = currentTeam?.isLeader ?? false; + const isPersonalTeam = currentTeam?.isPersonal ?? true; + + return ( + + {children} + + ); +} + +export function useSaaSTeam() { + const context = useContext(SaaSTeamContext); + if (context === undefined) { + throw new Error("useSaaSTeam must be used within a SaaSTeamProvider"); + } + return context; +} + +export { SaaSTeamContext }; +export type { Team, TeamMember, TeamInvitation }; From 8dde4262ec73f5c866e5b6a2465b0a1709c0e60f Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:57:08 +0100 Subject: [PATCH 27/80] feat(policies): backend-driven policy enforcement (frontend) (#6598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the **Policies** feature (proprietary, behind the `POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed tool pipeline on documents, docked in the right tool sidebar alongside Tools. ## Highlights - **Policy catalog** — 5 categories; **Security** is wired (redact PII + sanitize), the others are marked "Coming soon". - **Backend as source of truth** — policies persist via the Policies engine (`/api/v1/policies`), one policy per category, with a local cache + offline fallback. - **Auto-run** — enabled policies run on every uploaded file: dispatch → poll → import outputs into the workspace. - **Security redact config** — PII preset dropdown + custom word/regex entry + advanced options; tool params map to the backend endpoint fields. - **Activity feed** with retry on failures; **file badges** showing which policies ran on a file (sidebar + files page), tinted to the policy colour. - Reuses the **Watched Folders** engine for each policy's backing folder; policy-owned folders are filtered out of the Watched Folders UI. ## Notes - Gated by `POLICIES_ENABLED` (true in proprietary, false in core) — unreachable in the open-source build. - Frontend-only diff; depends on the backend Policies engine and the merged Watched Folders feature. --- frontend/.storybook/main.ts | 22 +- .../public/locales/en-GB/translation.toml | 3 + .../core/components/filesPage/FileGrid.tsx | 29 + .../core/components/filesPage/FilesPage.css | 18 + .../components/policies/PoliciesSidebar.tsx | 38 ++ .../policies/PolicyAutoRunController.tsx | 11 + .../core/components/shared/FileSidebar.tsx | 5 + .../components/shared/FileSidebarFileItem.css | 19 + .../components/shared/FileSidebarFileItem.tsx | 32 + .../shared/RainbowThemeProvider.tsx | 16 +- .../core/components/tools/RightSidebar.tsx | 231 ++++--- .../src/core/components/tools/ToolPanel.css | 23 + .../editor/src/core/constants/featureFlags.ts | 15 +- .../src/core/hooks/useAllWatchedFolders.ts | 4 +- .../src/core/hooks/usePolicyFileBadges.ts | 10 + .../src/core/tests/helpers/api-stubs.ts | 8 + .../editor/src/core/types/watchedFolders.ts | 3 + .../components/policies/Policies.css | 438 +++++++++++++ .../components/policies/Policies.stories.tsx | 163 +++++ .../policies/PoliciesSidebar.test.tsx | 152 +++++ .../components/policies/PoliciesSidebar.tsx | 453 +++++++++++++ .../policies/PolicyAutoRunController.tsx | 11 + .../policies/PolicyDeleteConfirmModal.tsx | 46 ++ .../components/policies/PolicyDetailPanel.tsx | 279 ++++++++ .../components/policies/PolicyFieldRow.tsx | 88 +++ .../components/policies/PolicyPiiField.tsx | 61 ++ .../policies/PolicyRedactConfig.tsx | 128 ++++ .../components/policies/PolicySetupWizard.tsx | 602 ++++++++++++++++++ .../components/policies/PolicyToolConfig.tsx | 96 +++ .../policies/PolicyToolConfigStep.tsx | 114 ++++ .../policies/PolicyWorkflowStep.tsx | 61 ++ .../proprietary/components/policies/README.md | 86 +++ .../policies/policyRunStore.test.ts | 81 +++ .../components/policies/policyRunStore.ts | 155 +++++ .../policies/policySelectionStore.ts | 71 +++ .../components/policies/policyStatus.ts | 27 + .../components/policies/policyToolChains.ts | 18 + .../components/policies/policyValues.ts | 16 + .../components/policies/usePolicyAutoRun.ts | 205 ++++++ .../src/proprietary/constants/featureFlags.ts | 17 +- .../data/policyDefinitions.test.ts | 23 + .../proprietary/data/policyDefinitions.tsx | 365 +++++++++++ .../src/proprietary/hooks/usePolicies.test.ts | 155 +++++ .../src/proprietary/hooks/usePolicies.ts | 282 ++++++++ .../src/proprietary/hooks/usePolicyCatalog.ts | 16 + .../proprietary/hooks/usePolicyFileBadges.ts | 43 ++ .../proprietary/hooks/useWatchedFolders.ts | 3 +- .../src/proprietary/services/policyApi.ts | 99 +++ .../src/proprietary/services/policyBackend.ts | 95 +++ .../src/proprietary/services/policyCatalog.ts | 43 ++ .../services/policyFolders.test.ts | 85 +++ .../src/proprietary/services/policyFolders.ts | 137 ++++ .../services/policyLiveData.test.ts | 97 +++ .../proprietary/services/policyLiveData.ts | 100 +++ .../services/policyPipeline.test.ts | 157 +++++ .../proprietary/services/policyPipeline.ts | 291 +++++++++ .../services/policyStorage.test.ts | 72 +++ .../src/proprietary/services/policyStorage.ts | 111 ++++ .../editor/src/proprietary/types/policies.ts | 211 ++++++ frontend/editor/vite.config.ts | 9 + frontend/editor/vitest.config.ts | 21 + frontend/shared/components/Checkbox.css | 6 + frontend/shared/components/Checkbox.tsx | 9 +- frontend/shared/components/ChipFlow.css | 12 + .../shared/components/ChipFlow.stories.tsx | 25 + frontend/shared/components/ChipFlow.tsx | 47 ++ frontend/shared/components/DataRow.css | 23 + .../shared/components/DataRow.stories.tsx | 38 ++ frontend/shared/components/DataRow.tsx | 41 ++ frontend/shared/components/IconBadge.css | 35 + .../shared/components/IconBadge.stories.tsx | 42 ++ frontend/shared/components/IconBadge.tsx | 40 ++ frontend/shared/components/ListRow.css | 88 +++ .../shared/components/ListRow.stories.tsx | 80 +++ frontend/shared/components/ListRow.tsx | 79 +++ frontend/shared/components/MetricCard.css | 12 + frontend/shared/components/MetricCard.tsx | 4 + frontend/shared/components/NavItem.css | 45 ++ frontend/shared/components/NavItem.tsx | 18 +- frontend/shared/components/PanelHeader.css | 16 +- frontend/shared/components/PanelHeader.tsx | 15 +- frontend/shared/components/SectionHeader.css | 33 + .../components/SectionHeader.stories.tsx | 36 ++ frontend/shared/components/SectionHeader.tsx | 66 ++ frontend/shared/components/Select.css | 19 + frontend/shared/components/SettingsRow.css | 30 + .../shared/components/SettingsRow.stories.tsx | 82 +++ frontend/shared/components/SettingsRow.tsx | 39 ++ frontend/shared/components/StepIndicator.css | 27 + .../components/StepIndicator.stories.tsx | 29 + frontend/shared/components/StepIndicator.tsx | 43 ++ 91 files changed, 7250 insertions(+), 99 deletions(-) create mode 100644 frontend/editor/src/core/components/policies/PoliciesSidebar.tsx create mode 100644 frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx create mode 100644 frontend/editor/src/core/hooks/usePolicyFileBadges.ts create mode 100644 frontend/editor/src/proprietary/components/policies/Policies.css create mode 100644 frontend/editor/src/proprietary/components/policies/Policies.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/README.md create mode 100644 frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyRunStore.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policySelectionStore.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyStatus.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyToolChains.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyValues.ts create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts create mode 100644 frontend/editor/src/proprietary/data/policyDefinitions.test.ts create mode 100644 frontend/editor/src/proprietary/data/policyDefinitions.tsx create mode 100644 frontend/editor/src/proprietary/hooks/usePolicies.test.ts create mode 100644 frontend/editor/src/proprietary/hooks/usePolicies.ts create mode 100644 frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts create mode 100644 frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts create mode 100644 frontend/editor/src/proprietary/services/policyApi.ts create mode 100644 frontend/editor/src/proprietary/services/policyBackend.ts create mode 100644 frontend/editor/src/proprietary/services/policyCatalog.ts create mode 100644 frontend/editor/src/proprietary/services/policyFolders.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyFolders.ts create mode 100644 frontend/editor/src/proprietary/services/policyLiveData.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyLiveData.ts create mode 100644 frontend/editor/src/proprietary/services/policyPipeline.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyPipeline.ts create mode 100644 frontend/editor/src/proprietary/services/policyStorage.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyStorage.ts create mode 100644 frontend/editor/src/proprietary/types/policies.ts create mode 100644 frontend/shared/components/ChipFlow.css create mode 100644 frontend/shared/components/ChipFlow.stories.tsx create mode 100644 frontend/shared/components/ChipFlow.tsx create mode 100644 frontend/shared/components/DataRow.css create mode 100644 frontend/shared/components/DataRow.stories.tsx create mode 100644 frontend/shared/components/DataRow.tsx create mode 100644 frontend/shared/components/IconBadge.css create mode 100644 frontend/shared/components/IconBadge.stories.tsx create mode 100644 frontend/shared/components/IconBadge.tsx create mode 100644 frontend/shared/components/ListRow.css create mode 100644 frontend/shared/components/ListRow.stories.tsx create mode 100644 frontend/shared/components/ListRow.tsx create mode 100644 frontend/shared/components/SectionHeader.css create mode 100644 frontend/shared/components/SectionHeader.stories.tsx create mode 100644 frontend/shared/components/SectionHeader.tsx create mode 100644 frontend/shared/components/SettingsRow.css create mode 100644 frontend/shared/components/SettingsRow.stories.tsx create mode 100644 frontend/shared/components/SettingsRow.tsx create mode 100644 frontend/shared/components/StepIndicator.css create mode 100644 frontend/shared/components/StepIndicator.stories.tsx create mode 100644 frontend/shared/components/StepIndicator.tsx diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 7a53d8e9c..0797a367a 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -1,12 +1,14 @@ import { resolve } from "node:path"; import type { StorybookConfig } from "@storybook/react-vite"; +import tsconfigPaths from "vite-tsconfig-paths"; /** * Storybook 9 ships essentials, interactions, and docs as built-ins, so the * addon list is just the extras we want: theme switching + a11y auditing. * - * Story files live next to their components in shared/ and portal/src/. - * MDX docs pages live in portal/src/docs/. + * Story files live next to their components in shared/, portal/src/, and + * editor/src/ — the design system is shared by BOTH apps, so both surface + * their stories here. MDX docs pages live in portal/src/docs/. */ const config: StorybookConfig = { stories: [ @@ -14,6 +16,7 @@ const config: StorybookConfig = { "../portal/src/**/*.stories.@(ts|tsx)", "../shared/**/*.mdx", "../shared/**/*.stories.@(ts|tsx)", + "../editor/src/**/*.stories.@(ts|tsx)", ], addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], framework: { @@ -28,13 +31,26 @@ const config: StorybookConfig = { staticDirs: ["../portal/public"], viteFinal: async (config) => { // Wire @portal/* and @shared/* aliases directly on the Storybook bundler so - // story imports resolve without needing the portal's vite config. + // portal story imports resolve without needing the portal's vite config. config.resolve = config.resolve ?? {}; config.resolve.alias = { ...(config.resolve.alias ?? {}), "@portal": resolve(__dirname, "../portal/src"), "@shared": resolve(__dirname, "../shared"), }; + // Editor stories import via @app/* (proprietary→core fallback), @core/* and + // @proprietary/*. Resolve them exactly the way the editor's own build does — + // through vite-tsconfig-paths against the proprietary vite tsconfig — so the + // shared Storybook can host editor components without duplicating the alias + // map here. + config.plugins = config.plugins ?? []; + config.plugins.push( + tsconfigPaths({ + projects: [ + resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"), + ], + }), + ); return config; }, }; diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 388933960..d06fd55e4 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -8193,6 +8193,9 @@ confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" +[policies] +sidebarTitle = "Policies" + [watchedFolders] alreadyInFolder = "Already in this folder" deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index 6249565ff..46644b1b7 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; import MoreVertIcon from "@mui/icons-material/MoreVert"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import FolderIcon from "@mui/icons-material/Folder"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; @@ -17,6 +18,7 @@ import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; import { FileId } from "@app/types/file"; import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; import { useFolders } from "@app/contexts/FolderContext"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { @@ -572,6 +574,31 @@ function FolderCard({ ); } +/** Shield badges for the policies that have run on a file. */ +function PolicyBadges({ fileId }: { fileId: string }) { + const badges = usePolicyFileBadges().get(fileId) ?? []; + if (badges.length === 0) return null; + return ( + + {badges.slice(0, 3).map((policy) => ( + + + + + + ))} + + ); +} + interface FileCardProps { file: StirlingFileStub; isSelected: boolean; @@ -731,6 +758,7 @@ function FileCard({ {fileSize} · {fileDate} +
@@ -1315,6 +1343,7 @@ function FileRow({ )} + {isInWorkspace && ( diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 9b3b7b206..f1e0e3db0 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -484,6 +484,24 @@ gap: 0.4rem; } +/* Policy activity badges (a shield per policy that has run on the file). */ +.files-page-policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} +.files-page-policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + /* `color` set inline to the policy accent; tint follows it. */ + background: color-mix(in srgb, currentColor 16%, transparent); +} + /* Parent-folder breadcrumb shown on cards/rows during recursive search so the user can tell which folder each hit lives in without navigating. */ .files-page-card-path { diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx new file mode 100644 index 000000000..d787c6435 --- /dev/null +++ b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx @@ -0,0 +1,38 @@ +/** + * Core stubs for the right-rail Policies UI. + * + * The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx} + * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary + * build is active. Core builds render nothing, so the right rail shows only the + * tool list unchanged. + */ + +import type { ReactNode } from "react"; + +/** Whether the right rail should host the Policies section. False in core. */ +export function usePoliciesEnabled(): boolean { + return false; +} + +/** + * Whether a policy is open (its detail should take over the rail). Always false + * in core; proprietary bridges to the policy-selection store. + */ +export function usePolicyDetailActive(): boolean { + return false; +} + +/** Collapsible policy list rendered above the Tools section. Null in core. */ +export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) { + return null; +} + +/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */ +export function PolicyDetailTakeover() { + return null; +} + +/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */ +export function PoliciesCollapsedButton(_props: { onExpand: () => void }) { + return null; +} diff --git a/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx new file mode 100644 index 000000000..b34fadc7a --- /dev/null +++ b/frontend/editor/src/core/components/policies/PolicyAutoRunController.tsx @@ -0,0 +1,11 @@ +/** + * Core stub for the policy auto-run controller. + * + * The real implementation lives in + * {@code proprietary/components/policies/PolicyAutoRunController.tsx} and shadows + * this stub via the {@code @app/*} alias cascade in the proprietary build. Core + * builds have no Policies feature, so this renders nothing and runs no policies. + */ +export function PolicyAutoRunController() { + return null; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index fb02ebf09..ed4e6e75d 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -42,6 +42,7 @@ import type { FileId } from "@app/types/file"; import { FileItem } from "@app/components/shared/FileSidebarFileItem"; import { useFolderMembership } from "@app/hooks/useFolderMembership"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; +import { usePolicyFileBadges } from "@app/hooks/usePolicyFileBadges"; import { setWatchedFolderDraggedFileIds, clearWatchedFolderDraggedFileIds, @@ -131,6 +132,7 @@ const FileSidebar = forwardRef( // the workbench). The same map drives the per-file membership dots. const folderMembership = useFolderMembership(); const allFolders = useAllWatchedFolders(); + const policyFileBadges = usePolicyFileBadges(); const folderById = useMemo( () => new Map(allFolders.map((f) => [f.id, f])), [allFolders], @@ -892,6 +894,9 @@ const FileSidebar = forwardRef( onDragStart={handleWatchedFolderDragStart} folders={memberFolders} onFolderClick={openWatchedFolder} + policies={ + policyFileBadges.get(stub.id as string) ?? [] + } /> ); })} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 3529f0165..81ca24ae0 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -134,6 +134,25 @@ text-overflow: ellipsis; } +/* ---- Policy activity badges (a shield per policy that has run on the file) ---- */ +.file-sidebar-policy-badges { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} +.file-sidebar-policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 4px; + /* `color` is set inline to the policy's accent; the tint follows it. */ + color: var(--text-secondary); + background: color-mix(in srgb, currentColor 16%, transparent); +} + /* ---- Folder membership tags ---- */ .file-sidebar-folder-tags { display: flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index c03930864..1b2f566f1 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -4,6 +4,7 @@ import { Tooltip } from "@mantine/core"; import { useTranslation } from "react-i18next"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import type { FileId } from "@app/types/file"; import { FileDocIcon } from "@app/components/shared/FileDocIcon"; import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; @@ -125,6 +126,14 @@ export interface FileItemFolderRef { accentColor: string; } +/** A policy that has run on this file, used for the activity badges. */ +export interface FileItemPolicyRef { + id: string; + name: string; + /** CSS colour for the badge (matches the policy's accent). */ + accentColor: string; +} + export interface FileItemProps { fileId: FileId; name: string; @@ -143,9 +152,12 @@ export interface FileItemProps { folders?: FileItemFolderRef[]; /** Clicking a membership dot opens that folder. */ onFolderClick?: (folderId: string) => void; + /** Policies that have run on this file — rendered as small shield badges. */ + policies?: FileItemPolicyRef[]; } const MAX_VISIBLE_FOLDER_TAGS = 2; +const MAX_VISIBLE_POLICY_BADGES = 3; export function FileItem({ fileId, @@ -162,6 +174,7 @@ export function FileItem({ onDragStart, folders = [], onFolderClick, + policies = [], }: FileItemProps) { const { t } = useTranslation(); const ext = getFileExtension(name); @@ -237,6 +250,25 @@ export function FileItem({ {dateLabel && typeLabel ? " · " : ""} {typeLabel} + {policies.length > 0 && ( + + {policies.slice(0, MAX_VISIBLE_POLICY_BADGES).map((policy) => ( + + + + + + ))} + + )} {folders.length > 0 && ( diff --git a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx index 25ddffaa7..0e6ba64a2 100644 --- a/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx +++ b/frontend/editor/src/core/components/shared/RainbowThemeProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, ReactNode } from "react"; +import { createContext, useContext, useEffect, ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { useRainbowTheme } from "@app/hooks/useRainbowTheme"; import { mantineTheme } from "@app/theme/mantineTheme"; @@ -7,6 +7,10 @@ import { ToastProvider } from "@app/components/toast"; import ToastRenderer from "@app/components/toast/ToastRenderer"; import { ToastPortalBinder } from "@app/components/toast"; import type { ThemeMode } from "@app/constants/theme"; +// SUI shared design-system tokens (used by @shared/components). Additive — its +// var names don't clash with the editor's own theme.css. The effect below +// bridges Mantine's color scheme to the `data-theme` attribute SUI keys on. +import "@shared/tokens/tokens.css"; interface RainbowThemeContextType { themeMode: ThemeMode; @@ -40,6 +44,16 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const mantineColorScheme = rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; + // Bridge the resolved scheme to the `data-theme` attribute SUI's tokens.css + // keys its dark palette on (the editor otherwise only sets Mantine's + // data-mantine-color-scheme), so @shared/components theme correctly. + useEffect(() => { + document.documentElement.setAttribute( + "data-theme", + mantineColorScheme === "dark" ? "dark" : "light", + ); + }, [mantineColorScheme]); + return ( { + withViewTransition(() => { + if (readerMode) setReaderMode(false); + setLeftPanelView("toolPicker"); + if (!sidebarsVisible) setSidebarsVisible(true); + setAllToolsView(false); + setSearchQuery(""); + }); + }; + // The header shows [back] [search] when we have somewhere to go back to — // i.e. the user is in a specific tool, or already in the all-tools/search view. const inToolView = leftPanelView !== "toolPicker"; // Show X (close) button only when there's somewhere to go back to. const showCloseButton = inToolView || allToolsView; - // Show search input whenever there's a close button, or when agents are off and - // we're in the default tool-picker view (search filters the full list inline). + // Policies sit above the tool list in the default tool-picker view. + const showPolicies = + policiesEnabled && !allToolsView && leftPanelView === "toolPicker"; + // When Policies are shown, the search moves OUT of the header to sit between + // the Policies and Tools sections (separating them); otherwise it stays in the + // header. Show the header search when there's a close button, or when agents + // are off in the default tool-picker view (inline filtering). + const showInlineSearch = showPolicies && !showCloseButton; const showHeaderSearch = - showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker"); + !showInlineSearch && + (showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker")); const handleHeaderBack = () => { if (inToolView) { @@ -201,11 +231,21 @@ export default function RightSidebar() { const showAgents = agentsEnabled && !allToolsView && leftPanelView === "toolPicker"; + // The detail takeover replaces the tool list ONLY in the same default view — + // never over an open tool or the all-tools view (which must keep priority). + // A lingering selection is harmless: it stays hidden behind a tool and the + // list/takeover reappears on return to the picker (as in the prototype). + const policyDetailActive = rawPolicyDetailActive && showPolicies; + + // The rail widens when a policy detail takes it over — the tool list is fine + // at 18.5rem, but the policy detail/wizard/settings need more breathing room. + const expandedWidth = policyDetailActive ? "25rem" : "18.5rem"; + const computedWidth = () => { if (isMobile) return "100%"; if (isChatOpen) return `${chatWidthPx}px`; if (!isPanelVisible) return "3.5rem"; - return "18.5rem"; + return expandedWidth; }; // Collapsed rail: show favourites + recommended tools as icons. @@ -248,6 +288,8 @@ export default function RightSidebar() { ...(isChatDragging ? { transition: "none" } : {}), }} > + {/* Headless: enforces enabled policies on every uploaded file. */} + {policiesEnabled && } {!fullscreenExpanded && isChatOpen && (
@@ -288,6 +330,7 @@ export default function RightSidebar() {
+
{collapsedRailItems.map(({ id, tool }) => ( -
- {activeTool ? ( -
- - - - - {activeTool.name} - -
- ) : showHeaderSearch ? ( -
- + +
+ ) : ( + <> + {!showPolicies && ( +
+ {activeTool ? ( +
+ + + + + {activeTool.name} + +
+ ) : showHeaderSearch ? ( +
+ +
+ ) : ( + showAgents && ( + + {t("agents.section_title", "Agents")} + + ) + )} + {showCloseButton ? ( + + + + ) : ( + + + + )} +
+ )} + + {showAgents && } + + {showPolicies && ( + + + + } /> -
- ) : ( - showAgents && ( - - {t("agents.section_title", "Agents")} - - ) - )} - {showCloseButton ? ( - - - - ) : ( - - - - )} -
+ )} - {showAgents && } + {showInlineSearch && ( +
+ +
+ )} - + + + )}
)} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 592c83771..103a24ad9 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -201,6 +201,13 @@ border-color: var(--border-subtle) !important; } +/* The collapse/expand toggle keeps a stable identity across the collapsed strip + and the expanded header, so a view transition translates it between the two + (same size) instead of cross-fading/scaling it ("big then small"). */ +.tool-panel__toggle-vt { + view-transition-name: sidebar-toggle; +} + .tool-panel__expand-btn svg { color: var(--text-secondary) !important; } @@ -271,6 +278,22 @@ width: 100%; } +/* Search that separates the Policies section from the Tools list below it. */ +.tool-panel__between-search { + padding: 0.5rem 0.75rem 0.25rem; + border-top: 1px solid var(--border-subtle, var(--color-border)); +} + +.tool-panel__between-search .search-input-container { + width: 100%; + margin-bottom: 0; +} + +/* Slightly recessed search field so it reads as distinct from the panel. */ +.tool-panel__between-search input { + background-color: var(--bg-muted); +} + .tool-panel__active-tool-pill { display: inline-flex; align-items: center; diff --git a/frontend/editor/src/core/constants/featureFlags.ts b/frontend/editor/src/core/constants/featureFlags.ts index 3a2e3d7a2..0ada1cc67 100644 --- a/frontend/editor/src/core/constants/featureFlags.ts +++ b/frontend/editor/src/core/constants/featureFlags.ts @@ -4,11 +4,18 @@ */ /** - * Watched Folders (a.k.a. Watched Folders). Disabled for now — gates both the - * custom workbench registration (seeding, URL sync, view) and the sidebar - * entry point, so the feature is unreachable from the UI. Flip to `true` to - * restore access. + * Watched Folders. Disabled for now — gates both the custom workbench + * registration (seeding, URL sync, view) and the sidebar entry point, so the + * feature is unreachable from the UI. Flip to `true` to restore access. */ // Annotated as `boolean` (not the literal `false`) so call sites aren't treated // as constant/unreachable conditions by the type checker and linter. export const WATCHED_FOLDERS_ENABLED: boolean = false; + +/** + * Policies — a proprietary, automation-backed feature (like Watched Folders but + * backend-driven, with non-folder triggers). The implementation lives under + * `proprietary/`; this core value stays `false` so the shared sidebar entry + * never appears in the open-source build. + */ +export const POLICIES_ENABLED: boolean = false; diff --git a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts index 8abc81a46..b1dbc2f57 100644 --- a/frontend/editor/src/core/hooks/useAllWatchedFolders.ts +++ b/frontend/editor/src/core/hooks/useAllWatchedFolders.ts @@ -17,7 +17,9 @@ export function useAllWatchedFolders(): WatchedFolder[] { useEffect(() => { const load = async () => { try { - setFolders(await watchedFolderStorage.getAllFolders()); + // Policy-owned folders are managed by Policies, not shown here. + const all = await watchedFolderStorage.getAllFolders(); + setFolders(all.filter((f) => !f.policyCategoryId)); } catch (err) { console.error("Failed to load smart folders:", err); } diff --git a/frontend/editor/src/core/hooks/usePolicyFileBadges.ts b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts new file mode 100644 index 000000000..e3297f9bd --- /dev/null +++ b/frontend/editor/src/core/hooks/usePolicyFileBadges.ts @@ -0,0 +1,10 @@ +import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; + +/** + * Policies that have run on each file, keyed by fileId — drives the shield + * badges in the file sidebar. Empty in core; the proprietary build shadows this + * with an implementation backed by the policy run store. + */ +export function usePolicyFileBadges(): Map { + return new Map(); +} diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index a8291a181..1618e8fa2 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -248,6 +248,14 @@ export async function mockAppApis( await page.route("**/api/v1/info/wau", (route: Route) => route.fulfill({ json: { count: 0 } }), ); + + // Policies (proprietary): the reconcile fires GET /api/v1/policies on app + // load. Return an empty list so the stubbed (backend-free) env stays clean — + // no failed request polluting the console, and the auto-run controller has + // nothing to dispatch. + await page.route("**/api/v1/policies", (route: Route) => + route.fulfill({ json: [] }), + ); } /** diff --git a/frontend/editor/src/core/types/watchedFolders.ts b/frontend/editor/src/core/types/watchedFolders.ts index adfdc1640..ce8f7374e 100644 --- a/frontend/editor/src/core/types/watchedFolders.ts +++ b/frontend/editor/src/core/types/watchedFolders.ts @@ -25,6 +25,9 @@ export interface WatchedFolder { hasOutputDirectory?: boolean; // true when a local FS output folder is configured /** Where input files come from. Default: 'idb' (dropped/sidebar files stay in browser). */ inputSource?: "idb" | "local-folder"; + /** Set when this folder is the backing store for a Policy (its category id). + * Policy-owned folders are filtered out of the Watched Folders UI. */ + policyCategoryId?: string; } export interface FolderFileMetadata { diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css new file mode 100644 index 000000000..fc6e24324 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -0,0 +1,438 @@ +/* ============================ Policies ============================ */ +/* The Policies surface is docked in the right tool sidebar: a list section */ +/* above Tools, a detail takeover that replaces Tools when a policy is open, */ +/* and a collapsed-rail of policy icons. */ +/* */ +/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */ +/* (@shared/components). The bespoke .pol-* bits below are thin layout */ +/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */ +/* scale and colour to the SUI token set so it reads as one product. */ + +/* ---- List ---- */ +.pol-list { + width: 100%; + display: flex; + flex-direction: column; +} +/* Header row: optional leading control (sidebar collapse) + the SectionHeader, + mirroring the back-button + title layout inside an open policy. */ +.pol-list-head { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); +} +.pol-list-head .sui-sectionhdr { + flex: 1; + min-width: 0; +} +/* Small "what is a policy?" info button on the header. */ +/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */ +.pol-info-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + border: none; + background: none; + cursor: pointer; + transition: opacity var(--motion-fast); +} +.pol-info-btn:hover { + opacity: 0.7; +} +.pol-list-rows { + display: flex; + flex-direction: column; + padding: var(--space-1) var(--space-1_5); + gap: 0.0625rem; +} +/* A policy row: tinted icon tile + label + trailing status/CTA. */ +.pol-row { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-1_5) var(--space-2); + border: none; + border-radius: var(--radius-lg); + background: transparent; + cursor: pointer; + text-align: left; + transition: background var(--motion-fast); +} +.pol-row:hover { + background: var(--color-bg-hover); +} +.pol-row:focus-visible { + outline: 2px solid var(--color-blue); + outline-offset: -2px; +} +.pol-row-label { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); +} +.pol-row-trail { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-left: auto; +} +/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */ +.pol-row-setup { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-blue); +} +/* Trailing drill-in chevron on each policy row (after the status / CTA). */ +.pol-row-chevron { + color: var(--color-text-4); + flex-shrink: 0; +} + +/* Retry button on a failed activity row. */ +/* Expandable error text in the activity feed — long backend errors are clamped + and collapsed by default so they don't blow up the row. */ +.pol-activity-error { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; +} +.pol-activity-error__text { + white-space: pre-wrap; + word-break: break-word; +} +.pol-activity-error__text--clamped { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.pol-activity-error__toggle { + border: none; + background: none; + padding: 0; + font-size: inherit; + font-weight: 600; + color: var(--color-blue); + cursor: pointer; +} +.pol-activity-error__toggle:hover { + text-decoration: underline; +} + +/* Locked "Coming soon" row — muted, not interactive. */ +.pol-row--soon { + opacity: 0.55; + cursor: default; +} +.pol-row--soon:hover { + background: transparent; +} +.pol-row-soon { + font-size: 0.6875rem; + font-weight: 500; + color: var(--color-text-4); + white-space: nowrap; +} + +/* In-progress activity icon spins gently. */ +.pol-spin { + animation: pol-spin 1.2s linear infinite; +} +@keyframes pol-spin { + to { + transform: rotate(360deg); + } +} + +/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */ +.pol-tool-config { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.pol-tool-head { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); +} +.pol-tool-icon { + display: inline-flex; + align-items: center; + color: var(--color-text-3); +} +.pol-tool-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); + margin-right: auto; +} +.pol-tool-body { + padding: 0 var(--space-3) var(--space-3); + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +/* ---- Detail container (wizard / narrative / settings share this) ---- */ +.pol-detail { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + max-width: 32rem; + width: 100%; +} + +/* ---- Step indicator (wraps a SUI StepIndicator) ---- */ +.pol-steps { + padding: var(--space-3) var(--space-5); + border-bottom: 1px solid var(--color-border); +} + +/* ---- Scroll body ---- */ +.pol-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: var(--space-3) var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.pol-desc { + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-4); + margin: 0; +} +.pol-section-label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-4); + /* Bottom gap so the label doesn't hug its card/chips. */ + margin: 0 0 var(--space-2); +} + +/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */ +.pol-field { + padding: 0.7rem 0.875rem; + background: var(--color-surface); +} +.pol-field:not([data-first]) { + border-top: 1px solid var(--color-border); +} +.pol-field-label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); +} +.pol-field-count { + font-size: 0.6875rem; + color: var(--color-text-4); +} +.pol-field-chips-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-2); +} +.pol-field-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-1_5); +} + +/* ---- Sources ---- */ +.pol-source { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0.7rem 0.875rem; + cursor: pointer; + background: var(--color-surface); +} +.pol-source:not([data-first]) { + border-top: 1px solid var(--color-border); +} + +/* ---- Doc types ---- */ +.pol-link { + font-size: 0.75rem; + font-weight: 500; + color: var(--color-blue); + background: none; + border: none; + padding: 0; + cursor: pointer; +} +.pol-doctypes-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.7rem 0.875rem; +} +.pol-doctypes { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-2) 0.875rem var(--space-3); + border-top: 1px solid var(--color-border); +} + +/* ---- Summary ---- */ +.pol-summary-head { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); +} +.pol-summary-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-1); +} +.pol-summary-rows { + display: flex; + flex-direction: column; + gap: var(--space-1_5); +} +/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */ +.pol-muted { + color: var(--color-text-4); +} + +/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */ +.pol-rule-flow { + margin-bottom: var(--space-2); +} + +/* ---- Meta / note ---- */ +.pol-meta-row { + display: flex; + gap: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--color-border); +} +.pol-meta-item { + display: inline-flex; + align-items: center; + gap: var(--space-1_5); + font-size: 0.75rem; + color: var(--color-text-4); +} +.pol-note { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-lg); + background: var(--color-bg-muted); + font-size: 0.75rem; + color: var(--color-text-4); +} + +/* ---- Stats: one grouped card with three divided columns. ---- */ +.pol-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); +} +.pol-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-0_5); + padding: var(--space-3) var(--space-2); + text-align: center; +} +.pol-stat:not(:first-child) { + border-left: 1px solid var(--color-border); +} +.pol-stat-value { + font-size: 1rem; + font-weight: 600; + line-height: 1.1; + color: var(--color-text-1); +} +.pol-stat-label { + font-size: 0.6875rem; + color: var(--color-text-4); +} + +/* ---- Footer (hosts SUI Buttons) ---- */ +.pol-footer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--color-border); + flex-shrink: 0; +} +.pol-footer-end { + justify-content: flex-end; +} + +/* ---- Detail takeover (fills the rail when a policy is open) ---- */ +.pol-takeover { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* ---- Collapsed rail policy icons ---- */ +.pol-crail { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; +} +.pol-crail-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--color-text-3); + cursor: pointer; + transition: background var(--motion-fast); +} +.pol-crail-btn:hover { + background: var(--color-blue-light); +} +.pol-crail-btn[data-status="active"] { + color: var(--color-blue); +} +.pol-crail-btn[data-status="paused"] { + color: var(--color-amber); +} +.pol-crail-dot { + position: absolute; + top: 0.1rem; + right: 0.1rem; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + border: 2px solid var(--bg-toolbar, var(--color-surface)); +} +.pol-crail-dot[data-status="active"] { + background: var(--color-green); +} +.pol-crail-dot[data-status="paused"] { + background: var(--color-amber); +} diff --git a/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx new file mode 100644 index 000000000..863501000 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/Policies.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel"; +import { PoliciesSection } from "@app/components/policies/PoliciesSidebar"; +import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions"; +import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; +import "@app/components/policies/Policies.css"; + +/** + * The Policies surface lives in the editor's right tool sidebar. These stories + * render the three rich detail surfaces (narrative / setup wizard / settings) + * inside a frame the width of the rail when a policy is open (25rem), so the + * SUI composition can be reviewed in isolation — no app shell, login, or + * backend required. Toggle the Storybook theme switcher to check dark mode. + */ +const RAIL_WIDTH = "25rem"; + +/** Frame that mimics the right rail's open width + surface so the panel reads true. */ +function RailFrame({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!; +const security = POLICY_CATEGORIES.find((c) => c.id === "security")!; + +// Illustrative activity/stats for the static stories. In the app these are +// derived live from the user's real uploaded files (see policyLiveData). +const sampleActivity: PolicyActivityItem[] = [ + { + doc: "MSA_Acme_2026.pdf", + action: "1.2 MB • enforced on upload", + time: "2h ago", + status: "enforced", + }, + { + doc: "scan_002.pdf", + action: "Low confidence • flagged for review", + time: "Yesterday", + status: "flagged", + }, +]; +const sampleStats: PolicyStats = { + enforced: 1284, + dataProcessed: "3.2 GB", + activeFor: "18d", +}; + +const noop = () => {}; + +const meta: Meta = { + title: "Editor/Policies", + parameters: { layout: "centered" }, +}; +export default meta; + +type Story = StoryObj; + +/** Configured policy, running — Enforces / Activity / Stats narrative. */ +export const DetailActive: Story = { + render: () => ( + + + + ), +}; + +/** Configured policy, paused — amber accent + warning badge. */ +export const DetailPaused: Story = { + render: () => ( + + + + ), +}; + +/** Read-only view for a member without configure permission. */ +export const DetailManaged: Story = { + render: () => ( + + + + ), +}; + +/** The policy list section (above Tools). */ +export const ListSection: Story = { + render: () => ( +
+ +
+ ), +}; + +// Note: the setup + edit wizard now embeds the Watch Folders automation builder +// (its Workflow step), which needs the ToolWorkflow context — so the wizard is +// exercised in-app, not via an isolated story here. diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx new file mode 100644 index 000000000..0eabd502f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx @@ -0,0 +1,152 @@ +import "fake-indexeddb/auto"; +import type { ReactNode } from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// The shared Tooltip (used by the "what is a policy?" info button) pulls in +// preferences/sidebar contexts we don't set up here — passthrough it. +vi.mock("@app/components/shared/Tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, +})); + +// The wizard's Workflow step embeds the Watch Folders automation builder, which +// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to +// hand back the seed automation, so the wizard's submit still completes. +vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({ + AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" }, + PolicyWorkflowStep: (props: { + automation: unknown; + saveTriggerRef: { current: (() => void) | null }; + onComplete: (automation: unknown, toolRegistry: unknown) => void; + }) => { + props.saveTriggerRef.current = () => props.onComplete(props.automation, {}); + return null; + }, +})); + +// The backend is the source of truth, but these UI tests run offline: list +// rejects (so the mount reconcile keeps the local cache), while save/delete +// resolve so the enable flow completes. +vi.mock("@app/services/policyApi", () => ({ + listPolicies: vi.fn().mockRejectedValue(new Error("offline")), + savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({ + ...p, + id: p.id && p.id.length > 0 ? p.id : "be-test", + })), + getPolicy: vi.fn(), + deletePolicy: vi.fn().mockResolvedValue(undefined), + runStoredPolicy: vi.fn(), + runPolicyPipeline: vi.fn(), + getPolicyRun: vi.fn(), +})); + +// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB); +// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids. +if (typeof globalThis.crypto?.randomUUID !== "function") { + const orig = globalThis.crypto; + vi.stubGlobal("crypto", { + getRandomValues: orig?.getRandomValues?.bind(orig), + randomUUID: () => + `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + }); +} +import { + PoliciesSection, + PolicyDetailTakeover, + usePolicyDetailActive, +} from "@app/components/policies/PoliciesSidebar"; +import { resetPolicySelection } from "@app/components/policies/policySelectionStore"; + +/** + * Mirrors how RightSidebar swaps the policy list for the detail takeover: the + * list shows above Tools when nothing is open, the takeover replaces it when a + * policy is selected. + */ +function PoliciesHost() { + const active = usePolicyDetailActive(); + return active ? : ; +} + +function renderHost() { + return render( + + + , + ); +} + +describe("Policies right-sidebar surface", () => { + beforeEach(() => { + localStorage.clear(); + // Seed a configured Security policy (the only live category) in the local + // cache. The backend list is mocked to reject (offline), so the mount + // reconcile leaves it in place — giving the narrative tests a policy to open. + localStorage.setItem( + "stirling-policies-state", + JSON.stringify({ + security: { + configured: true, + status: "active", + sources: ["editor"], + scopeTypes: [], + reviewerEmail: "", + fieldValues: {}, + }, + }), + ); + resetPolicySelection(); + }); + + it("renders the policy list with every category", () => { + renderHost(); + expect(screen.getByText("Policies")).toBeInTheDocument(); + for (const label of [ + "Ingestion", + "Security", + "Compliance", + "Routing", + "Retention", + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it("shows Security as active and the unbuilt categories as Coming soon", () => { + renderHost(); + expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); + // Ingestion, Compliance, Routing, Retention are locked for this release. + expect(screen.getAllByText("Coming soon")).toHaveLength(4); + }); + + it("does not open a Coming soon policy when its row is clicked", () => { + renderHost(); + fireEvent.click(screen.getByText("Ingestion")); + // The locked row isn't a button — we stay on the list, nothing opens. + expect(screen.getByText("Policies")).toBeInTheDocument(); + expect(screen.queryByText("Enforces")).not.toBeInTheDocument(); + }); + + it("opens the narrative view when a live policy is clicked", () => { + renderHost(); + fireEvent.click(screen.getByText("Security")); + expect(screen.getByText("Enforces")).toBeInTheDocument(); + expect(screen.getByText("Edit Settings")).toBeInTheDocument(); + }); + + it("shows an honest empty activity feed when no files have been uploaded", async () => { + renderHost(); + fireEvent.click(screen.getByText("Security")); + expect(screen.getByText("Recent Activity")).toBeInTheDocument(); + // Activity is derived from real uploads; with none, the empty state shows. + expect(await screen.findByText("No activity yet")).toBeInTheDocument(); + }); + + it("returns to the list via the back button", () => { + renderHost(); + fireEvent.click(screen.getByText("Security")); + expect(screen.getByText("Enforces")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("Back")); + expect(screen.getByText("Policies")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx new file mode 100644 index 000000000..2028063fe --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -0,0 +1,453 @@ +/** + * Proprietary implementation of the right-sidebar Policies surface. + * + * Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx} + * via the {@code @app/*} alias cascade. Three slots, all driven by the shared + * {@link policySelectionStore} so they stay in sync: + * • {@link PoliciesSection} — the collapsible policy list, rendered above the + * Tools section in {@code RightSidebar} when no policy is open. + * • {@link PolicyDetailTakeover} — the detail / wizard / settings view, which + * replaces the Tools area when a policy is open. + * • {@link PoliciesCollapsedButton} — the icon rail shown when the sidebar is + * collapsed; clicking an icon selects the policy and expands the rail. + */ + +import { useState, useEffect, useMemo, type ReactNode } from "react"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { usePolicies } from "@app/hooks/usePolicies"; +import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog"; +import { getPolicyAutomation } from "@app/services/policyFolders"; +import { watchedFolderStorage } from "@app/services/watchedFolderStorage"; +import { runsToActivity, runsToStats } from "@app/services/policyLiveData"; +import { usePolicyRuns } from "@app/components/policies/policyRunStore"; +import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun"; +import type { FileId } from "@app/types/file"; +import type { + AutomationConfig, + AutomationOperation, +} from "@app/types/automation"; +import type { WatchedFolder } from "@app/types/watchedFolders"; +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; +import { IconBadge } from "@shared/components/IconBadge"; +import { + deriveRowStatus, + STATUS_LABEL, + ROW_ACCENT, +} from "@app/components/policies/policyStatus"; +import { StatusBadge } from "@shared/components/StatusBadge"; +import { SectionHeader } from "@shared/components/SectionHeader"; +import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard"; +import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel"; +import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal"; +import type { PolicyConfigResult } from "@app/types/policies"; +import { + usePolicySelection, + selectPolicy, + setPolicyDetailView, + closePolicy, +} from "@app/components/policies/policySelectionStore"; +import "@app/components/policies/Policies.css"; + +/** localStorage key persisting the Policies section's expand/collapse state. */ +const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed"; + +/** Whether the right rail should host the Policies section. True in proprietary. */ +export function usePoliciesEnabled(): boolean { + return POLICIES_ENABLED; +} + +/** + * Whether a policy is open — i.e. its detail view should take over the rail in + * place of the tool list. False when the feature is off or nothing is selected. + */ +export function usePolicyDetailActive(): boolean { + const { selectedId } = usePolicySelection(); + return POLICIES_ENABLED && selectedId != null; +} + +/** The collapsible policy list, rendered above the Tools section. */ +export function PoliciesSection({ + leadingControl, +}: { + /** Optional control rendered to the left of the header (e.g. the sidebar + * collapse button), mirroring the back-button + title in a policy. */ + leadingControl?: ReactNode; +} = {}) { + const pol = usePolicies(); + const { categories } = usePolicyCatalog(); + // Persist the expand/collapse state across refreshes. + const [expanded, setExpanded] = useState(() => { + try { + return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1"; + } catch { + return true; + } + }); + const toggleExpanded = () => + setExpanded((open) => { + const next = !open; + try { + localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1"); + } catch { + // Best-effort; ignore quota/availability failures. + } + return next; + }); + + if (!POLICIES_ENABLED) return null; + + // The header tally counts every CONFIGURED policy (active + paused), not just + // the active ones. + const configuredCount = categories.filter( + (c) => pol.policies[c.id]?.configured, + ).length; + + return ( +
+
+ {leadingControl} + + + + +
+ + {expanded && ( + <> +
+ {categories.map((cat) => { + if (cat.comingSoon) { + return ( +
+ + {cat.icon} + + {cat.label} + + Coming soon + +
+ ); + } + const status = deriveRowStatus(pol.policies[cat.id]); + return ( + + ); + })} +
+ + )} +
+ ); +} + +/** + * The open-policy view — narrative detail, setup wizard, or edit-settings — + * which replaces the Tools area while a policy is selected. + */ +export function PolicyDetailTakeover() { + const pol = usePolicies(); + const { categories, configs, sources, docTypes } = usePolicyCatalog(); + const { selectedId, detailView } = usePolicySelection(); + + // The configured policy's backing folder + automation (its real, editable + // pipeline). `reloadKey` bumps after the edit modal saves so the detail + // reflects the new steps. Falls back to the preset's rules when unconfigured. + const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined; + const [steps, setSteps] = useState([]); + const [backingFolder, setBackingFolder] = useState( + null, + ); + const [backingAutomation, setBackingAutomation] = + useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [confirmingDelete, setConfirmingDelete] = useState(false); + useEffect(() => { + if (!folderId) { + setSteps([]); + setBackingFolder(null); + setBackingAutomation(null); + return; + } + let cancelled = false; + void (async () => { + const [folder, automation] = await Promise.all([ + watchedFolderStorage.getFolder(folderId), + getPolicyAutomation(folderId), + ]); + if (cancelled) return; + setBackingFolder(folder); + setBackingAutomation(automation); + setSteps(automation?.operations ?? []); + })(); + return () => { + cancelled = true; + }; + }, [folderId, reloadKey]); + + // Activity/stats come from the real backend runs the auto-run controller fires + // on every upload (policyRunStore), filtered to this policy's category. The + // store is reactive, so the feed updates live as runs progress — no polling + // here (the controller does the run-status polling). + const allRuns = usePolicyRuns(); + const categoryRuns = useMemo( + () => allRuns.filter((r) => r.categoryId === selectedId), + [allRuns, selectedId], + ); + + if (!POLICIES_ENABLED || selectedId == null) return null; + + const category = categories.find((c) => c.id === selectedId); + const state = pol.policies[selectedId]; + const config = configs[selectedId]; + if (!category || !state || !config) return null; + // Coming-soon categories can't be opened (the list row is locked anyway). + if (category.comingSoon) return null; + + const status = deriveRowStatus(state); + + const onSetupClassification = () => { + const classifier = categories.find((c) => c.providesClassification); + if (classifier) selectPolicy(classifier.id); + }; + + // Preset (tool-chain) policies configure via the wizard's locked tool-config + // step instead of the add/remove builder; the wizard fires onCommitConfig for + // them (and onComplete for builder-based categories). One commit path serves + // both first-time configure and edits. + const commitConfig = (result: PolicyConfigResult) => + pol.commitPolicyConfig(selectedId, result).then(() => { + setReloadKey((k) => k + 1); + setPolicyDetailView("detail"); + }); + + // Setup: the shared wizard in create mode. + if (!state.configured) { + return ( + closePolicy()} + onComplete={(result) => + pol + .enablePolicy(selectedId, result) + .then(() => setPolicyDetailView("detail")) + } + onCommitConfig={commitConfig} + onSetupClassification={onSetupClassification} + /> + ); + } + + // Edit: the same wizard in edit mode, pre-filled — so editing has the settings + // steps (not just the workflow). Wait for the backing automation to load so + // the workflow step edits the real pipeline. + if (detailView === "settings" && pol.canConfigure) { + if (!backingAutomation) { + return ( +
+
+

Loading…

+
+
+ ); + } + return ( + setPolicyDetailView("detail")} + onComplete={(result) => + pol.savePolicyConfig(selectedId, result).then(() => { + setReloadKey((k) => k + 1); + setPolicyDetailView("detail"); + }) + } + onCommitConfig={commitConfig} + onSetupClassification={onSetupClassification} + /> + ); + } + + return ( + <> + closePolicy()} + onEditSettings={() => { + // Seeded/active policies may have no backing folder yet — create one + // from the preset so there's a workflow to edit, then open settings. + void pol + .ensurePolicyFolder(selectedId) + .then(() => setPolicyDetailView("settings")); + }} + onTogglePause={() => + status === "paused" + ? pol.resumePolicy(selectedId) + : pol.pausePolicy(selectedId) + } + onDelete={() => setConfirmingDelete(true)} + onRetry={(item) => { + if (item.fileId && state.backendId) { + void runPolicyOnFile( + selectedId, + state.backendId, + item.fileId as FileId, + item.doc, + ); + } + }} + /> + {confirmingDelete && ( + setConfirmingDelete(false)} + onConfirm={() => { + setConfirmingDelete(false); + closePolicy(); + void pol.deletePolicy(selectedId); + }} + /> + )} + + ); +} + +/** + * Collapsed-rail policy icons. Each tints blue when active and carries a small + * status dot (green active / amber paused). Clicking selects the policy and + * expands the rail. + */ +export function PoliciesCollapsedButton({ + onExpand, +}: { + onExpand: () => void; +}) { + const pol = usePolicies(); + const { categories } = usePolicyCatalog(); + + if (!POLICIES_ENABLED) return null; + + return ( + <> +
+ {categories + .filter((cat) => !cat.comingSoon) + .map((cat) => { + const status = deriveRowStatus(pol.policies[cat.id]); + const suffix = + status === "active" + ? " (Active)" + : status === "paused" + ? " (Paused)" + : ""; + return ( + + + + ); + })} +
+
+ + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx new file mode 100644 index 000000000..7c4e92194 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyAutoRunController.tsx @@ -0,0 +1,11 @@ +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; + +/** + * Headless controller that drives policy auto-run (enforce every enabled policy + * on every uploaded file). Mounted once wherever the editor is open so runs fire + * regardless of whether the policy panel is visible. Renders nothing. + */ +export function PolicyAutoRunController() { + usePolicyAutoRun(); + return null; +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx new file mode 100644 index 000000000..02b46928c --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx @@ -0,0 +1,46 @@ +import { Modal, Text, Button, Stack, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +interface PolicyDeleteConfirmModalProps { + opened: boolean; + /** The policy's display label (the category name). */ + label: string; + onConfirm: () => void; + onCancel: () => void; +} + +/** Confirm before deleting a policy — removing it discards its backing workflow. */ +export function PolicyDeleteConfirmModal({ + opened, + label, + onConfirm, + onCancel, +}: PolicyDeleteConfirmModalProps) { + const { t } = useTranslation(); + return ( + + + + {t( + "policies.deleteConfirmBody", + "This removes the policy and its workflow. Documents already processed are not affected.", + )} + + + + + + + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx new file mode 100644 index 000000000..bda878299 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -0,0 +1,279 @@ +import { useState } from "react"; +import PublicIcon from "@mui/icons-material/Public"; +import ScheduleIcon from "@mui/icons-material/Schedule"; +import HistoryIcon from "@mui/icons-material/History"; +import DescriptionIcon from "@mui/icons-material/Description"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import LockIcon from "@mui/icons-material/Lock"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import { PanelHeader } from "@shared/components/PanelHeader"; +import { Card } from "@shared/components/Card"; +import { ChipFlow } from "@shared/components/ChipFlow"; +import { StatusBadge } from "@shared/components/StatusBadge"; +import { EmptyState } from "@shared/components/EmptyState"; +import { Button } from "@shared/components/Button"; +import { Banner } from "@shared/components/Banner"; +import { ListRow } from "@shared/components/ListRow"; +import type { + PolicyActivityItem, + PolicyCategory, + PolicyConfigDef, + PolicyRowStatus, + PolicyStats, +} from "@app/types/policies"; +import type { AutomationOperation } from "@app/types/automation"; + +interface PolicyDetailPanelProps { + category: PolicyCategory; + config: PolicyConfigDef; + /** Derived display status. */ + status: PolicyRowStatus; + /** + * The policy's real configured steps (from its backing automation). When + * present these drive the Enforces flow; otherwise the preset's decorative + * `rules` are shown (e.g. before configuration). + */ + steps?: AutomationOperation[]; + /** Activity feed derived from the user's files; empty until files exist. */ + activity?: PolicyActivityItem[]; + /** Summary stats derived from the user's files. */ + stats?: PolicyStats; + canConfigure: boolean; + /** Default (built-in) policies aren't deletable, so the Delete action hides. */ + canDelete: boolean; + onBack: () => void; + onEditSettings: () => void; + onTogglePause: () => void; + onDelete: () => void; + /** Re-run a failed activity item's policy on its file. */ + onRetry?: (item: PolicyActivityItem) => void; +} + +/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */ +function humanizeOperation(op: string): string { + return op + .replace(/([A-Z])/g, " $1") + .replace(/^./, (c) => c.toUpperCase()) + .trim(); +} + +/** + * A failed run's error in the activity feed. Backend errors can be long (or + * multi-line stack traces) and would otherwise blow up the row, so anything + * lengthy is clamped and collapsed by default with a Show more/less toggle. + * Short messages (e.g. "Enforcement failed") render plainly with no toggle. + */ +function ActivityError({ message }: { message: string }) { + const [expanded, setExpanded] = useState(false); + const needsToggle = message.length > 80 || message.includes("\n"); + if (!needsToggle) return <>{message}; + return ( + + + {message} + + + + ); +} + +/** Narrative view for a configured policy (Enforces / Activity / Stats). */ +export function PolicyDetailPanel({ + category, + config, + status, + steps, + activity, + stats, + canConfigure, + canDelete, + onBack, + onEditSettings, + onTogglePause, + onDelete, + onRetry, +}: PolicyDetailPanelProps) { + const isPaused = status === "paused"; + // Real configured steps drive the flow; fall back to the preset's rule labels. + const enforceItems = + steps && steps.length > 0 + ? steps.map((s) => humanizeOperation(s.operation)) + : config.rules; + // Activity + stats are derived from the user's real files; until they load (or + // if none exist) show an honest empty feed / zeroed stats. + const activityItems = activity ?? []; + const statValues = stats ?? { + enforced: 0, + dataProcessed: "0 B", + activeFor: "—", + }; + return ( +
+ + {isPaused ? "Paused" : "Active"} + + } + /> + +
+ {/* Enforces */} +
+

Enforces

+ +
+ +
+
+ + + {config.scopeLabel} + + + + On every upload + +
+
+ + Originals stay untouched • Enforced version saved alongside +
+
+
+ + {/* Recent Activity */} +
+

Recent Activity

+ {activityItems.length > 0 ? ( + + {activityItems.map((item, i) => ( + 0} + leadingTone={ + item.status === "flagged" + ? "warning" + : item.status === "processing" + ? "info" + : "success" + } + leading={ + item.status === "flagged" ? ( + + ) : item.status === "processing" ? ( + + ) : ( + + ) + } + title={item.doc} + description={ + item.status === "flagged" ? ( + + ) : ( + item.action + ) + } + meta={item.time} + trailing={ + item.status === "flagged" && onRetry ? ( + + ) : undefined + } + /> + ))} + + ) : ( + + } + title="No activity yet" + description="Documents will appear here once this policy runs." + /> + + )} +
+ + {/* Stats — one grouped card with divided columns, intentionally + unlabelled for a quiet summary footer. */} + +
+
+ + {statValues.enforced.toLocaleString()} + + Docs enforced +
+
+ {statValues.dataProcessed} + Data processed +
+
+ {statValues.activeFor} + Active +
+
+
+ + {!canConfigure && ( + } + description="Managed by your organization. Contact an admin to change settings." + /> + )} +
+ + {canConfigure && ( +
+ {canDelete && ( + + )} + + +
+ )} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx new file mode 100644 index 000000000..ff848100c --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx @@ -0,0 +1,88 @@ +import { ToggleSwitch } from "@shared/components/ToggleSwitch"; +import { Select } from "@shared/components/Select"; +import { Input } from "@shared/components/Input"; +import { Chip } from "@shared/components/Chip"; +import { SettingsRow } from "@shared/components/SettingsRow"; +import type { PolicyField } from "@app/types/policies"; + +interface PolicyFieldRowProps { + field: PolicyField; + /** Effective current value (override or definition default). */ + value: boolean | string | string[]; + onChange: (value: boolean | string | string[]) => void; + /** First row in a group omits the top divider. */ + first?: boolean; +} + +/** + * Renders one policy setting: toggle, select, multi-select chips, or text. + * Controlled — the parent owns the value. Uses SUI controls (ToggleSwitch / + * Select / Input / Chip) so it matches the rest of the policy surface. + */ +export function PolicyFieldRow({ + field, + value, + onChange, + first, +}: PolicyFieldRowProps) { + if (field.type === "chips") { + const selected = Array.isArray(value) ? value : []; + const toggle = (opt: string) => + onChange( + selected.includes(opt) + ? selected.filter((o) => o !== opt) + : [...selected, opt], + ); + return ( +
+
+ {field.label} + {selected.length} selected +
+
+ {(field.options ?? []).map((opt) => ( + toggle(opt)} + > + {opt} + + ))} +
+
+ ); + } + + const control = + field.type === "toggle" ? ( + onChange(checked)} + aria-label={field.label} + /> + ) : field.type === "select" ? ( + onChange(e.target.value)} + aria-label={field.label} + /> + ); + + return ( +
+ +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx new file mode 100644 index 000000000..ed09a0d1d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx @@ -0,0 +1,61 @@ +import { MultiSelect } from "@mantine/core"; +import { PII_PRESETS } from "@app/data/policyDefinitions"; + +/** The set of preset regexes — used to separate preset words from custom ones. */ +export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern)); +const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern])); +const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value])); + +interface PolicyPiiFieldProps { + parameters: Record; + onChange: (parameters: Record) => void; + disabled?: boolean; +} + +/** + * The redact step's PII preset picker — a dropdown of common PII types that + * writes the matching regexes into `wordsToRedact` (automatic + regex). It only + * manages the preset entries; any custom patterns the user added separately are + * left untouched, so this sits alongside the custom-entry field rather than + * owning the whole list. + */ +export function PolicyPiiField({ + parameters, + onChange, + disabled, +}: PolicyPiiFieldProps) { + const words = Array.isArray(parameters.wordsToRedact) + ? (parameters.wordsToRedact as string[]) + : []; + const selected = words + .map((w) => VALUE_BY_PATTERN.get(w)) + .filter((v): v is string => Boolean(v)); + + const handleChange = (values: string[]) => { + const presetPatterns = values + .map((v) => PATTERN_BY_VALUE.get(v)) + .filter((p): p is string => Boolean(p)); + // Keep the user's custom patterns; only swap the preset selection. + const customWords = words.filter((w) => !PRESET_PATTERNS.has(w)); + onChange({ + ...parameters, + mode: "automatic", + useRegex: true, + wordsToRedact: [...presetPatterns, ...customWords], + }); + }; + + return ( + ({ value: p.value, label: p.label }))} + value={selected} + onChange={handleChange} + disabled={disabled} + clearable + checkIconPosition="right" + /> + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx new file mode 100644 index 000000000..0d203d20d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx @@ -0,0 +1,128 @@ +import { useEffect } from "react"; +import { + Stack, + Divider, + ColorInput, + NumberInput, + Checkbox, +} from "@mantine/core"; +import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput"; +import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; +import { + PolicyPiiField, + PRESET_PATTERNS, +} from "@app/components/policies/PolicyPiiField"; + +interface PolicyRedactConfigProps { + parameters: Record; + onChange: (parameters: Record) => void; + disabled?: boolean; +} + +/** + * Redact configuration for a policy: a PII preset dropdown plus a separate field + * for the user's own words / regexes, then the advanced redact options. The two + * lists are kept disjoint — the dropdown owns the preset patterns, the custom + * field owns everything else — so selecting presets and typing custom patterns + * don't clobber each other. Regex matching is always on (a plain word is a + * literal regex), so the Use-Regex toggle is omitted. Mode stays automatic since + * policies run headless and manual redaction needs the canvas. + */ +export function PolicyRedactConfig({ + parameters, + onChange, + disabled, +}: PolicyRedactConfigProps) { + const words = Array.isArray(parameters.wordsToRedact) + ? (parameters.wordsToRedact as string[]) + : []; + // Split the stored list: presets are driven by the dropdown, the rest by the + // custom field below. Each editor only ever rewrites its own half. + const presetWords = words.filter((w) => PRESET_PATTERNS.has(w)); + const customWords = words.filter((w) => !PRESET_PATTERNS.has(w)); + + const redactColor = + typeof parameters.redactColor === "string" + ? parameters.redactColor + : "#000000"; + const customPadding = + typeof parameters.customPadding === "number" + ? parameters.customPadding + : 0.1; + const wholeWordSearch = parameters.wholeWordSearch === true; + const convertPDFToImage = parameters.convertPDFToImage !== false; // default on + + // Regex is always on for policies; heal any older/default-false value once on + // mount (empty deps — we only need to normalise the persisted flag). + useEffect(() => { + if (parameters.useRegex !== true) { + onChange({ ...parameters, useRegex: true }); + } + }, []); + + // Every edit keeps mode automatic + regex on (the two policy invariants). + const patch = (next: Record) => + onChange({ ...parameters, mode: "automatic", useRegex: true, ...next }); + + return ( + + + + + + + patch({ wordsToRedact: [...presetWords, ...next] }) + } + disabled={disabled} + /> + + + + patch({ redactColor: value })} + disabled={disabled} + size="sm" + format="hex" + popoverProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} + /> + + + patch({ customPadding: typeof value === "number" ? value : 0.1 }) + } + min={0} + max={10} + step={0.1} + disabled={disabled} + size="sm" + placeholder="0.1" + /> + + patch({ wholeWordSearch: e.currentTarget.checked })} + disabled={disabled} + size="sm" + /> + + patch({ convertPDFToImage: e.currentTarget.checked })} + disabled={disabled} + size="sm" + /> + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx new file mode 100644 index 000000000..7078277af --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -0,0 +1,602 @@ +import { useState, useMemo, useRef } from "react"; +import CloseIcon from "@mui/icons-material/Close"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { PanelHeader } from "@shared/components/PanelHeader"; +import { Card } from "@shared/components/Card"; +import { Button } from "@shared/components/Button"; +import { ChipFlow } from "@shared/components/ChipFlow"; +import { DataRow } from "@shared/components/DataRow"; +import { Input } from "@shared/components/Input"; +import { Select } from "@shared/components/Select"; +import { SettingsRow } from "@shared/components/SettingsRow"; +import { FormField } from "@shared/components/FormField"; +import { Checkbox } from "@shared/components/Checkbox"; +import { Banner } from "@shared/components/Banner"; +import { EmptyState } from "@shared/components/EmptyState"; +import { StepIndicator } from "@shared/components/StepIndicator"; +import { IconBadge } from "@shared/components/IconBadge"; +import type { + PolicyCategory, + PolicyConfigDef, + PolicyConfigResult, + PolicySource, + PolicyState, + PolicyWizardResult, +} from "@app/types/policies"; +import type { + AutomationConfig, + AutomationOperation, +} from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WatchedFolder } from "@app/types/watchedFolders"; +import { buildPipelineDefinition } from "@app/services/policyPipeline"; +import { useAuth } from "@app/auth/UseSession"; +import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow"; +import { resolveFieldValues } from "@app/components/policies/policyValues"; +import { + PolicyWorkflowStep, + AutomationMode, +} from "@app/components/policies/PolicyWorkflowStep"; +import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep"; +import { getPolicyToolChain } from "@app/components/policies/policyToolChains"; + +// Sources are always "editor" for this release, so the Sources step is dropped +// from the flow (its panel code is kept below for when other sources return). +const SOURCES_IN_FLOW = false; +const TOTAL_STEPS = SOURCES_IN_FLOW ? 4 : 3; + +interface PolicySetupWizardProps { + category: PolicyCategory; + config: PolicyConfigDef; + initial: PolicyState; + /** Sources a policy can run over (catalog-supplied). */ + sources: PolicySource[]; + /** Document types scope can be narrowed to (catalog-supplied). */ + docTypes: string[]; + canConfigure: boolean; + /** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */ + classificationEnabled: boolean; + /** "create" seeds the workflow from the preset; "edit" loads the backing automation. */ + mode?: "create" | "edit"; + /** The backing automation to edit (edit mode). */ + existingAutomation?: AutomationConfig; + /** The backing folder, to pre-fill output + retry settings (edit mode). */ + initialFolder?: WatchedFolder; + onCancel: () => void; + /** + * Fires on submit with the saved workflow + collected settings. May be async; + * if the returned promise rejects, the wizard re-enables submit and surfaces + * the failure rather than hanging on a permanently-disabled button. + */ + onComplete: (result: PolicyWizardResult) => void | Promise; + /** + * For preset (tool-chain) policies whose Workflow step is the locked tool + * config: fires instead of `onComplete`, carrying the configured tools as + * operations + mapped pipeline steps. When absent the wizard uses the + * add/remove builder + `onComplete`. + */ + onCommitConfig?: (result: PolicyConfigResult) => void | Promise; + onSetupClassification: () => void; +} + +/** + * The shared policy wizard, used for both setup and edit. Four steps: + * Workflow (the tool pipeline, reusing the Watch Folders builder) → Settings + * (the policy fields) → Sources → Review. The workflow builder is kept mounted + * across steps so the final action can trigger its save. + */ +export function PolicySetupWizard({ + category, + config, + initial, + sources: sourceDefs, + docTypes, + canConfigure, + classificationEnabled, + mode = "create", + existingAutomation, + initialFolder, + onCancel, + onComplete, + onCommitConfig, + onSetupClassification, +}: PolicySetupWizardProps) { + const isEdit = mode === "edit"; + // Preset (tool-chain) policies render the locked tool config as their Workflow + // step instead of the add/remove builder. + const toolChain = getPolicyToolChain(category.id); + const { user } = useAuth(); + const [step, setStep] = useState(1); + const [fieldValues, setFieldValues] = useState(() => + resolveFieldValues(config, initial), + ); + const [sources, setSources] = useState( + initial.sources.length ? initial.sources : ["editor"], + ); + const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0); + const [scopeTypes, setScopeTypes] = useState(initial.scopeTypes); + // Default flagged-document reviewer to the signed-in user. + const [reviewerEmail, setReviewerEmail] = useState( + initial.reviewerEmail || user?.email || "", + ); + // Output + retry settings — the real, working folder settings (the engine + // applies them). Pre-filled from the backing folder in edit mode. + const [outputMode, setOutputMode] = useState<"new_file" | "new_version">( + initialFolder?.outputMode ?? "new_file", + ); + const [outputName, setOutputName] = useState(initialFolder?.outputName ?? ""); + const [outputNamePosition, setOutputNamePosition] = useState< + "prefix" | "suffix" | "auto-number" + >(initialFolder?.outputNamePosition ?? "prefix"); + const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3); + const [retryDelayMinutes, setRetryDelayMinutes] = useState( + initialFolder?.retryDelayMinutes ?? 5, + ); + const workflowSave = useRef<(() => void) | null>(null); + const [submitting, setSubmitting] = useState(false); + const [saveError, setSaveError] = useState(null); + + // Seed the workflow builder: the backing automation in edit, else a synthetic + // config carrying the category preset's operations (created on save). + const seedAutomation = useMemo( + () => + existingAutomation ?? { + id: "", + name: `${category.label} Policy`, + description: `${category.label} policy workflow`, + icon: "WorkIcon", + operations: config.defaultOperations, + createdAt: "", + updatedAt: "", + }, + [existingAutomation, category.label, config.defaultOperations], + ); + + if (!canConfigure) { + return ( +
+ +
+ +
+
+ ); + } + + const back = () => + step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel(); + + const toggleSource = (id: string) => + setSources((prev) => + prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id], + ); + + // Once the builder persists the workflow, map its operations to backend + // endpoint paths (the registry only lives in the Workflow step) and hand the + // automation + built steps + settings to the host (which closes the wizard on + // success). If the host's async save rejects, recover so the submit button + // doesn't stay disabled forever. + const handleWorkflowSaved = ( + automation: AutomationConfig, + toolRegistry: Partial, + ) => { + const { definition, unresolved } = buildPipelineDefinition( + automation, + toolRegistry, + ); + Promise.resolve( + onComplete({ + automation, + fieldValues, + sources, + scopeTypes: scopeNarrow ? scopeTypes : [], + reviewerEmail, + folder: { + outputMode, + outputName: outputName.trim(), + outputNamePosition, + maxRetries, + retryDelayMinutes, + }, + pipelineSteps: definition.steps, + unresolvedOps: unresolved, + }), + ).catch(() => { + setSubmitting(false); + setSaveError("Couldn't save the policy. Please try again."); + }); + }; + + // Tool-chain policies: the locked config step emits its enabled tools as + // operations + mapped steps; hand them to the host's commit path. + const handleToolConfigSaved = ( + operations: AutomationOperation[], + pipelineSteps: { operation: string; parameters: Record }[], + unresolvedOps: string[], + ) => { + Promise.resolve( + onCommitConfig?.({ + operations, + pipelineSteps, + unresolvedOps, + fieldValues, + sources, + scopeTypes: scopeNarrow ? scopeTypes : [], + reviewerEmail, + folder: { + outputMode, + outputName: outputName.trim(), + outputNamePosition, + maxRetries, + retryDelayMinutes, + }, + }), + ).catch(() => { + setSubmitting(false); + setSaveError("Couldn't save the policy. Please try again."); + }); + }; + + // Final submit: guard against double-submit (the step stays mounted, so a + // second click would persist twice), then trigger the step's save. + const submit = () => { + if (submitting) return; + setSaveError(null); + setSubmitting(true); + workflowSave.current?.(); + }; + + // The builder couldn't save (e.g. no configured tools) — surface it and send + // the user back to the Workflow step to fix it. + const handleSaveFailed = () => { + setSubmitting(false); + setSaveError("Add at least one configured tool to the workflow first."); + setStep(1); + }; + + return ( +
+ } + /> + } + /> + +
+ +
+ +
+ {saveError && ( + } + description={saveError} + /> + )} + {/* Step 1 — Workflow. Kept mounted (hidden on other steps) so the final + submit can trigger its save. Preset (tool-chain) policies show the + locked, per-tool config; the rest show the add/remove builder. */} +
+ {toolChain ? ( + <> +

+ Configure the tools this policy runs on each document. +

+ + + ) : ( + <> +

+ Build the sequence of tools this policy runs on each document. +

+ + + )} +
+ + {step === 2 && ( + <> +

{category.desc}

+ + {config.fields.map((f, i) => ( + + setFieldValues((prev) => ({ ...prev, [f.key]: v })) + } + /> + ))} + + + {/* Real, working output + retry settings (applied by the engine). */} +

Output & retries

+ +
+ + setOutputMode( + e.target.value as "new_file" | "new_version", + ) + } + aria-label="Output mode" + options={[ + { value: "new_file", label: "New file" }, + { value: "new_version", label: "New version" }, + ]} + /> + } + /> +
+
+ setOutputName(e.target.value)} + placeholder="optional" + aria-label="Output name" + /> + } + /> +
+
+ + setOutputNamePosition( + e.target.value as "prefix" | "suffix" | "auto-number", + ) + } + aria-label="Name position" + options={[ + { value: "prefix", label: "Prefix" }, + { value: "suffix", label: "Suffix" }, + { value: "auto-number", label: "Auto-number" }, + ]} + /> + } + /> +
+
+ + setMaxRetries(Math.max(0, Number(e.target.value) || 0)) + } + aria-label="Max retries" + /> + } + /> +
+
+ + setRetryDelayMinutes( + Math.max(0, Number(e.target.value) || 0), + ) + } + aria-label="Retry delay minutes" + /> + } + /> +
+
+ + )} + + {/* Sources step — kept in code, out of the flow for this release + (SOURCES_IN_FLOW), since sources are always "editor" for now. */} + {SOURCES_IN_FLOW && step === 3 && ( + <> +

+ Choose where this policy runs and which document types it applies + to. +

+

Sources

+ + {sourceDefs.map((src, i) => ( +
+ toggleSource(src.id)} + leadingIcon={src.icon} + label={src.label} + description={src.desc} + /> +
+ ))} +
+ +

Document types

+ {!classificationEnabled ? ( + } + title="All document types" + description="Enable the Classification policy to filter by document type." + action={ + + } + /> + ) : ( + +
+ + {scopeTypes.length === 0 + ? "All document types" + : `${scopeTypes.length} types selected`} + + +
+ {scopeNarrow && ( +
+ {docTypes.map((dt) => ( + + setScopeTypes((prev) => + prev.includes(dt) + ? prev.filter((d) => d !== dt) + : [...prev, dt], + ) + } + label={dt} + /> + ))} +
+ )} +
+ )} + + )} + + {step === TOTAL_STEPS && ( + <> +

+ When Stirling has low confidence in an enforcement action, it will + send the document for human review. +

+

Reviewer

+ + + setReviewerEmail(e.target.value)} + placeholder="email@company.com" + /> + + + +

Summary

+ +
+ + {category.icon} + + + {category.label} Policy + +
+
+ + + + {SOURCES_IN_FLOW && ( + {sources.length} selected + )} + + {reviewerEmail || Not set} + +
+
+ + )} +
+ +
+ + {step < TOTAL_STEPS ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx new file mode 100644 index 000000000..497cad780 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx @@ -0,0 +1,96 @@ +import { Suspense } from "react"; +import { Loader } from "@mantine/core"; +import { ToggleSwitch } from "@shared/components/ToggleSwitch"; +import { Card } from "@shared/components/Card"; +import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { ToolId } from "@app/types/toolId"; + +/** One tool in a policy's fixed chain: whether it runs + its configured params. */ +export interface PolicyToolState { + /** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */ + operation: string; + /** Whether this tool runs as part of the policy (the per-tool on/off). */ + enabled: boolean; + /** Tool-specific parameters (the shape its endpoint accepts). */ + parameters: Record; +} + +interface PolicyToolConfigProps { + /** The policy's fixed tool chain — locked (no add/remove), only configurable. */ + tools: PolicyToolState[]; + toolRegistry: Partial; + onChange: (tools: PolicyToolState[]) => void; + /** Read-only when the policy is managed / the user can't configure. */ + editable?: boolean; +} + +/** + * Locked, configure-only tool panel for a policy. The chain is fixed (you can't + * add or remove tools); each tool is a section that renders its OWN settings form + * from the tool registry (`automationSettings`) — the same forms the automation + * builder uses — so the config is generated from the tools in the workflow, not + * hardcoded per policy. The parameters produced here are exactly what the backend + * engine POSTs to each tool's endpoint. + */ +export function PolicyToolConfig({ + tools, + toolRegistry, + onChange, + editable = true, +}: PolicyToolConfigProps) { + const patchTool = (index: number, patch: Partial) => + onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t))); + + return ( +
+ {tools.map((tool, index) => { + const entry = toolRegistry[tool.operation as ToolId]; + const Settings = entry?.automationSettings ?? null; + return ( + +
+ {entry?.icon} + + {entry?.name ?? tool.operation} + + patchTool(index, { enabled: checked })} + aria-label={`Enable ${entry?.name ?? tool.operation}`} + /> +
+ {tool.enabled && + (tool.operation === "redact" ? ( + // Redact has a bespoke config: PII preset dropdown + a custom + // word/regex field + advanced options, mode + regex locked on. +
+ patchTool(index, { parameters })} + disabled={!editable} + /> +
+ ) : Settings ? ( +
+ }> + + patchTool(index, { + parameters: { ...tool.parameters, [key]: value }, + }) + } + disabled={!editable} + /> + +
+ ) : null)} +
+ ); + })} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx new file mode 100644 index 000000000..472d1852f --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfigStep.tsx @@ -0,0 +1,114 @@ +import { useState, useEffect, type MutableRefObject } from "react"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { buildPipelineDefinition } from "@app/services/policyPipeline"; +import { + PolicyToolConfig, + type PolicyToolState, +} from "@app/components/policies/PolicyToolConfig"; +import type { ToolId } from "@app/types/toolId"; +import type { AutomationOperation } from "@app/types/automation"; + +/** + * Seed a tool's parameters: start from the tool's own registry defaults, overlay + * the preset's defaults (e.g. the PII patterns), then apply only the saved + * values the user actually changed from the tool default. This means a policy + * saved while a param was at its default (an empty redact list) still inherits + * the preset value, while genuine user edits are preserved. + */ +function seedToolParameters( + registryDefaults: Record, + presetParams: Record, + savedParams: Record, +): Record { + const merged: Record = { + ...registryDefaults, + ...presetParams, + }; + const eq = (a: unknown, b: unknown) => + JSON.stringify(a) === JSON.stringify(b); + for (const [key, value] of Object.entries(savedParams)) { + if (!eq(value, registryDefaults[key])) merged[key] = value; + } + return merged; +} + +interface PolicyToolConfigStepProps { + /** The fixed, configurable tool chain (locked set) for this policy. */ + chainIds: string[]; + /** Operations to seed enabled/params from (saved ops, or preset defaults). */ + initialOperations: AutomationOperation[]; + /** + * The preset's default operations. Their params seed any tool whose saved + * value is still at the tool's own default — so e.g. a policy saved before the + * PII patterns existed inherits them rather than running with an empty list. + */ + presetOperations: AutomationOperation[]; + /** Used to name the built pipeline definition. */ + categoryLabel: string; + /** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */ + saveTriggerRef: MutableRefObject<(() => void) | null>; + /** Emits the enabled tools as operations + the endpoint-mapped backend steps. */ + onComplete: ( + operations: AutomationOperation[], + pipelineSteps: { operation: string; parameters: Record }[], + unresolvedOps: string[], + ) => void; +} + +/** + * The wizard's Workflow step for preset (tool-chain) policies: the locked, + * per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder. + * Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and + * wires the wizard's submit trigger to emit the configured tools as operations + * + the endpoint-mapped pipeline steps. + */ +export function PolicyToolConfigStep({ + chainIds, + initialOperations, + presetOperations, + categoryLabel, + saveTriggerRef, + onComplete, +}: PolicyToolConfigStepProps) { + const { toolRegistry } = useToolWorkflow(); + + const [tools, setTools] = useState(() => + chainIds.map((op) => { + const saved = initialOperations.find((o) => o.operation === op); + const preset = presetOperations.find((o) => o.operation === op); + const defaults = (toolRegistry[op as ToolId]?.operationConfig + ?.defaultParameters ?? {}) as Record; + return { + operation: op, + enabled: Boolean(saved), + parameters: seedToolParameters( + defaults, + (preset?.parameters ?? {}) as Record, + (saved?.parameters ?? {}) as Record, + ), + }; + }), + ); + + // Re-wire the submit trigger whenever the tools change so it emits the latest. + useEffect(() => { + saveTriggerRef.current = () => { + const operations: AutomationOperation[] = tools + .filter((t) => t.enabled) + .map((t) => ({ operation: t.operation, parameters: t.parameters })); + const { definition, unresolved } = buildPipelineDefinition( + { name: `${categoryLabel} Policy`, operations }, + toolRegistry, + ); + onComplete(operations, definition.steps, unresolved); + }; + }, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]); + + return ( + + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx new file mode 100644 index 000000000..842bf07e8 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWorkflowStep.tsx @@ -0,0 +1,61 @@ +import type { MutableRefObject } from "react"; +import AutomationCreation from "@app/components/tools/automate/AutomationCreation"; +import { AutomationMode } from "@app/types/automation"; +import type { AutomationConfig } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; + +interface PolicyWorkflowStepProps { + /** + * The automation to seed/edit. For setup this is a synthetic config carrying + * the category preset's operations; for edit it's the policy's backing + * automation. + */ + automation: AutomationConfig; + /** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */ + mode: AutomationMode; + /** The host (wizard) triggers the builder's save imperatively from its footer. */ + saveTriggerRef: MutableRefObject<(() => void) | null>; + /** + * Called with the saved automation once the builder persists it, plus the tool + * registry (which lives here) so the wizard can map operations to backend + * endpoint paths without depending on the ToolWorkflow context itself. + */ + onComplete: ( + automation: AutomationConfig, + toolRegistry: Partial, + ) => void; + /** Called when save is triggered but the workflow isn't in a saveable state. */ + onSaveFailed?: () => void; +} + +/** + * The policy wizard's "Workflow" step: the Watch Folders automation builder + * ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept + * as its own component so the heavy builder + its ToolWorkflow dependency are + * isolated (and mockable in the rail tests). + */ +export function PolicyWorkflowStep({ + automation, + mode, + saveTriggerRef, + onComplete, + onSaveFailed, +}: PolicyWorkflowStepProps) { + const { toolRegistry } = useToolWorkflow(); + return ( + {}} + onComplete={(saved) => onComplete(saved, toolRegistry)} + onSaveFailed={onSaveFailed} + /> + ); +} + +export { AutomationMode }; diff --git a/frontend/editor/src/proprietary/components/policies/README.md b/frontend/editor/src/proprietary/components/policies/README.md new file mode 100644 index 000000000..df6535ba1 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/README.md @@ -0,0 +1,86 @@ +# Policies (frontend) + +Automation-backed document-enforcement policies — conceptually like Watch +Folders but backend-driven, with non-folder triggers (editor save/export, +device sweeps, cloud connectors). **This is the frontend only**: per-policy +state is persisted locally (localStorage) and activity + stats are derived from +your real uploaded files; server persistence + real enforcement land in a +follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary +build = on while in development, core build = off). + +## Layout + +| Path | Role | +|------|------| +| `types/policies.ts` | Type model (category, fields, state). | +| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. | +| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. | +| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). | +| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. | +| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. | +| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. | +| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. | +| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. | +| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). | +| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). | +| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. | +| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). | + +The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`; +`RightSidebar` (core) consumes the seam, so the section appears only in +proprietary builds. + +## Design system (SUI + Mantine) + +The surface is composed almost entirely from the shared SUI design system +(`@shared/components`), mixed with Mantine only where SUI has no equivalent. +SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`, +`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`, +`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`, +`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`, +`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`, +`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem` +accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`) +were **built up in SUI** as part of this work — each has a Storybook story. + +Bootstrapping: the editor loads `@shared/tokens/tokens.css` globally via +`RainbowThemeProvider`, which also mirrors the Mantine colour scheme onto +`` (SUI's dark palette keys on `data-theme`). A global +`@shared` alias in `editor/vite.config.ts` + `vitest.config.ts` resolves the +shared components' own `@shared/*.css` self-imports. + +The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding +(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI +`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI +token set. + +**Status-colour convention (locked):** blue = accent/identity (NavItem accent +bar, rail icon, detail Card accent — the prototype's blue); green `success` +StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot); +amber = paused. Configured rows render as raised cards (surface + border). + +## Faithful to the prototype + +5 categories (Ingestion, Security, Compliance, Routing, Retention), their full +field sets, the 3-step wizard (incl. the doc-type step gated behind the +Classification/ingestion policy), the configured narrative view (Enforces / +recent-activity feed / three-up stats), settings, the permission model +(owner/admin/member + solo), and the +docked right-sidebar placement: a collapsible **Policies** list above Tools, a +detail view that takes over the rail when a policy is open, and a collapsed-rail +of policy icons with active/paused status dots. + +## Deviations / follow-ups + +- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise + upgrade/commit/bespoke modals live in the Settings billing tab — a + billing-integration surface (Stripe/org state that doesn't exist yet), + deferred. The in-rail surface shows only the spend-limit warning chip, as in + the prototype's policy section. +- **Backend.** All persistence, enforcement, activity, and stats are mock. To + go live, replace `services/policyStorage.ts` and feed real activity/stats. + +## Tests + +`services/policyStorage.test.ts` (seed/update/reset/heal/events) and +`data/policyDefinitions.test.ts` (permission matrix + definition integrity). diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts new file mode 100644 index 000000000..8a62cbfcc --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + dispatchKey, + isDispatched, + markDispatched, + recordRunStart, + updateRun, + resetPolicyRuns, + type PolicyRunRecord, +} from "@app/components/policies/policyRunStore"; + +function rec(over: Partial): PolicyRunRecord { + return { + runId: "r1", + categoryId: "security", + fileId: "f1", + fileName: "f.pdf", + fileSize: 10, + status: "PENDING", + outputs: [], + error: null, + startedAt: 1, + ...over, + }; +} + +// The store reads localStorage at import; reset state + storage per test. +function read(key: string) { + return JSON.parse(localStorage.getItem(key) ?? "{}"); +} + +describe("policyRunStore", () => { + beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + }); + + it("records a run start and marks the (policy, file) pair dispatched", () => { + expect(isDispatched("security", "f1")).toBe(false); + recordRunStart(rec({})); + expect(isDispatched("security", "f1")).toBe(true); + const stored = read("stirling-policy-runs"); + expect(stored.runs).toHaveLength(1); + expect(stored.dispatched).toContain(dispatchKey("security", "f1")); + }); + + it("markDispatched is idempotent and independent of a run record", () => { + markDispatched("routing", "f9"); + markDispatched("routing", "f9"); + expect(isDispatched("routing", "f9")).toBe(true); + expect(read("stirling-policy-runs").dispatched).toHaveLength(1); + }); + + it("updateRun patches an in-flight run's status + outputs", () => { + recordRunStart(rec({ runId: "abc" })); + updateRun("abc", { + status: "COMPLETED", + outputs: [{ fileId: "out-1", fileName: "redacted.pdf" }], + }); + const run = read("stirling-policy-runs").runs[0]; + expect(run.status).toBe("COMPLETED"); + expect(run.outputs).toEqual([ + { fileId: "out-1", fileName: "redacted.pdf" }, + ]); + }); + + it("updateRun ignores an unknown run id", () => { + recordRunStart(rec({ runId: "abc", status: "PENDING" })); + updateRun("nope", { status: "FAILED" }); + expect(read("stirling-policy-runs").runs[0].status).toBe("PENDING"); + }); + + it("caps stored runs at 50, newest first", () => { + for (let i = 0; i < 55; i++) { + recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i })); + } + const runs = read("stirling-policy-runs").runs; + expect(runs).toHaveLength(50); + expect(runs[0].runId).toBe("r54"); // most recent + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts new file mode 100644 index 000000000..77eb931b5 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -0,0 +1,155 @@ +/** + * External store for real backend policy runs (Phase B: auto-run on upload). + * + * The auto-run controller fires a backend run for each enabled policy × each + * newly-uploaded file and records it here; the detail view's activity feed reads + * from it. `dispatched` keys (`categoryId:fileId`) ensure a given file is only + * ever run once per policy, surviving remounts via localStorage. + * + * Read with {@code useSyncExternalStore}; mutated by the controller. + */ + +import { useSyncExternalStore } from "react"; +import type { PolicyRunStatus } from "@app/services/policyPipeline"; + +export interface PolicyRunRecord { + runId: string; + categoryId: string; + fileId: string; + fileName: string; + fileSize: number; + status: PolicyRunStatus; + /** Output files (downloadable via /api/v1/general/files/{id}) once done. */ + outputs: { fileId: string; fileName: string }[]; + /** True once ALL outputs have been imported into the workspace. */ + imported?: boolean; + /** Output fileIds already imported — tracked per-file so a partial failure + * retries only the missing ones and never re-adds the ones that succeeded. */ + importedFileIds?: string[]; + error: string | null; + /** Epoch ms when the run was dispatched. */ + startedAt: number; +} + +interface RunState { + runs: PolicyRunRecord[]; + dispatched: string[]; +} + +const STORAGE_KEY = "stirling-policy-runs"; +/** Cap stored runs so the activity log can't grow without bound. */ +const MAX_RUNS = 50; + +function read(): RunState { + try { + const raw = + typeof localStorage !== "undefined" + ? localStorage.getItem(STORAGE_KEY) + : null; + if (raw) { + const parsed = JSON.parse(raw) as Partial; + return { + // Normalise older persisted records (which predate the `outputs` field) + // so consumers can always rely on `outputs` being an array. + runs: Array.isArray(parsed.runs) + ? parsed.runs.map((r) => ({ + ...r, + outputs: Array.isArray(r.outputs) ? r.outputs : [], + importedFileIds: Array.isArray(r.importedFileIds) + ? r.importedFileIds + : [], + })) + : [], + dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [], + }; + } + } catch { + // Corrupt/unavailable storage — start empty. + } + return { runs: [], dispatched: [] }; +} + +let state: RunState = read(); +const listeners = new Set<() => void>(); + +function emit() { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + } catch { + // Best-effort persistence. + } + for (const l of listeners) l(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): RunState { + return state; +} + +const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] }; +function getServerSnapshot(): RunState { + return SERVER_SNAPSHOT; +} + +/** Key identifying a single (policy, file) run attempt. */ +export function dispatchKey(categoryId: string, fileId: string): string { + return `${categoryId}:${fileId}`; +} + +/** Whether this (policy, file) pair has already been dispatched. */ +export function isDispatched(categoryId: string, fileId: string): boolean { + return state.dispatched.includes(dispatchKey(categoryId, fileId)); +} + +/** Record a newly-dispatched run (marks it dispatched + adds the record). */ +export function recordRunStart(record: PolicyRunRecord) { + const key = dispatchKey(record.categoryId, record.fileId); + state = { + runs: [record, ...state.runs].slice(0, MAX_RUNS), + dispatched: state.dispatched.includes(key) + ? state.dispatched + : [...state.dispatched, key], + }; + emit(); +} + +/** Mark a (policy, file) pair dispatched without a run (e.g. dispatch failed). */ +export function markDispatched(categoryId: string, fileId: string) { + const key = dispatchKey(categoryId, fileId); + if (state.dispatched.includes(key)) return; + state = { ...state, dispatched: [...state.dispatched, key] }; + emit(); +} + +/** Patch an in-flight run's status/outputs/error as it progresses. */ +export function updateRun(runId: string, patch: Partial) { + let changed = false; + const runs = state.runs.map((r) => { + if (r.runId !== runId) return r; + changed = true; + return { ...r, ...patch }; + }); + if (!changed) return; + state = { ...state, runs }; + emit(); +} + +/** Reset the store — used by tests to isolate it. */ +export function resetPolicyRuns() { + state = { runs: [], dispatched: [] }; + emit(); +} + +export function usePolicyRuns(): PolicyRunRecord[] { + return useSyncExternalStore( + subscribe, + () => getSnapshot().runs, + () => getServerSnapshot().runs, + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts new file mode 100644 index 000000000..451582b90 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts @@ -0,0 +1,71 @@ +/** + * Tiny external store for the currently-selected policy and its detail sub-view. + * + * The Policies surface is split across two slots in the right tool sidebar — the + * list section (above Tools) and the detail takeover (which replaces Tools when a + * policy is open) — plus the collapsed-rail icons. They live in different parts of + * {@code RightSidebar}'s tree, so selection can't be component-local useState. + * This module-level store (read via {@code useSyncExternalStore}) lets all three + * stay in sync without threading a context through the core sidebar. + */ + +import { useSyncExternalStore } from "react"; +import type { PolicyDetailView } from "@app/types/policies"; + +interface PolicySelection { + selectedId: string | null; + detailView: PolicyDetailView; +} + +let state: PolicySelection = { selectedId: null, detailView: "detail" }; +const listeners = new Set<() => void>(); + +function emit() { + for (const l of listeners) l(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): PolicySelection { + return state; +} + +/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */ +const SERVER_SNAPSHOT: PolicySelection = { + selectedId: null, + detailView: "detail", +}; +function getServerSnapshot(): PolicySelection { + return SERVER_SNAPSHOT; +} + +/** Open a policy's detail (resets the sub-view to the narrative). */ +export function selectPolicy(id: string | null) { + state = { selectedId: id, detailView: "detail" }; + emit(); +} + +/** Switch the open policy between its narrative and edit-settings sub-views. */ +export function setPolicyDetailView(view: PolicyDetailView) { + if (state.detailView === view) return; + state = { ...state, detailView: view }; + emit(); +} + +/** Close the open policy and return to the list. */ +export function closePolicy() { + selectPolicy(null); +} + +/** Reset to the initial state — used by tests to isolate the module store. */ +export function resetPolicySelection() { + state = { selectedId: null, detailView: "detail" }; + emit(); +} + +export function usePolicySelection(): PolicySelection { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts new file mode 100644 index 000000000..d3c3d8ea7 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -0,0 +1,27 @@ +import type { IconBadgeAccent } from "@shared/components/IconBadge"; +import type { PolicyRowStatus, PolicyState } from "@app/types/policies"; + +/** Derive a single row/detail status from a policy's persisted state. */ +export function deriveRowStatus( + state: PolicyState | undefined, +): PolicyRowStatus { + if (!state?.configured) return "setup"; + if (state.status === "paused") return "paused"; + return "active"; +} + +/** Human label for each row status. */ +export const STATUS_LABEL: Record = { + active: "Active", + paused: "Paused", + setup: "Set up", +}; + +/** A soft tinted icon tile per category — gives each policy a calm identity colour. */ +export const ROW_ACCENT: Record = { + ingestion: "blue", + security: "purple", + compliance: "green", + routing: "amber", + retention: "red", +}; diff --git a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts new file mode 100644 index 000000000..9af2fff4d --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts @@ -0,0 +1,18 @@ +/** + * The fixed, configurable tool chain per policy category — the locked set the + * config page shows (one section per tool). Tools can be configured + toggled + * on/off, but not added or removed. Each id is a frontend tool-registry key (and + * maps to that tool's backend endpoint via the registry's operationConfig). + * + * Only Security is wired today; other categories follow. + */ +export const POLICY_TOOL_CHAINS: Record = { + // Security: redact PII + watermark + sanitize (strips JS). Which are enabled + // by default comes from the preset's defaultOperations, not this list. + security: ["redact", "watermark", "sanitize"], +}; + +/** The configurable tool chain for a category, or null if it has none yet. */ +export function getPolicyToolChain(categoryId: string): string[] | null { + return POLICY_TOOL_CHAINS[categoryId] ?? null; +} diff --git a/frontend/editor/src/proprietary/components/policies/policyValues.ts b/frontend/editor/src/proprietary/components/policies/policyValues.ts new file mode 100644 index 000000000..844156656 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/policyValues.ts @@ -0,0 +1,16 @@ +import type { PolicyConfigDef, PolicyState } from "@app/types/policies"; + +/** + * Resolve each field's effective value for a policy: the saved override from + * state, falling back to the definition's default. + */ +export function resolveFieldValues( + config: PolicyConfigDef, + state: PolicyState, +): Record { + const out: Record = {}; + for (const f of config.fields) { + out[f.key] = state.fieldValues[f.key] ?? f.value; + } + return out; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts new file mode 100644 index 000000000..a9407a6d0 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -0,0 +1,205 @@ +/** + * Auto-run controller (Phase B): every enabled policy enforces on every uploaded + * file. Watches the session's files and, for each (active policy × not-yet-run + * file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it + * to completion, recording progress in {@link policyRunStore} for the activity + * feed. + * + * Headless — call it from {@link PolicyAutoRunController}, which is mounted once + * wherever the editor is open so enforcement happens regardless of whether the + * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked + * in the run store), so re-renders and remounts don't re-fire. + */ + +import { useEffect, useRef } from "react"; +import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; +import { fileStorage } from "@app/services/fileStorage"; +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import { + runStoredPolicy, + getPolicyRun, + downloadPolicyOutput, +} from "@app/services/policyApi"; +import type { PolicyRunStatus } from "@app/services/policyPipeline"; +import type { FileId } from "@app/types/file"; +import { usePolicies } from "@app/hooks/usePolicies"; +import { + isDispatched, + markDispatched, + recordRunStart, + updateRun, + usePolicyRuns, + type PolicyRunRecord, +} from "@app/components/policies/policyRunStore"; + +/** Poll cadence + cap for a single run's status (≈2.5 min worst case). */ +const POLL_MS = 2000; +const MAX_POLLS = 75; + +function isTerminal(status: PolicyRunStatus): boolean { + return ( + status === "COMPLETED" || status === "FAILED" || status === "CANCELLED" + ); +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function usePolicyAutoRun(): void { + const { fileStubs } = useAllFiles(); + const { addFiles } = useFileManagement(); + const { policies } = usePolicies(); + const runs = usePolicyRuns(); + // Run ids currently being polled / imported, so the effects never double-fire. + const polling = useRef>(new Set()); + const importing = useRef>(new Set()); + + // Dispatch: for each active policy × each session file not yet run, fire a run. + useEffect(() => { + if (!POLICIES_ENABLED) return; + const active = Object.entries(policies).filter( + ([, s]) => s.configured && s.status === "active" && s.backendId, + ); + for (const [categoryId, s] of active) { + for (const stub of fileStubs) { + if (isDispatched(categoryId, stub.id)) continue; + // runPolicyOnFile marks dispatched synchronously before its first await. + void runPolicyOnFile( + categoryId, + s.backendId as string, + stub.id, + stub.name, + ); + } + } + }, [fileStubs, policies]); + + // Poll each in-flight run to a terminal state. + useEffect(() => { + if (!POLICIES_ENABLED) return; + for (const run of runs) { + if (isTerminal(run.status) || polling.current.has(run.runId)) continue; + polling.current.add(run.runId); + void poll(run.runId).finally(() => polling.current.delete(run.runId)); + } + }, [runs]); + + // Import each completed run's outputs into the workspace (each output once), + // so the enforced file appears in the app rather than only on the backend. + useEffect(() => { + if (!POLICIES_ENABLED) return; + for (const run of runs) { + if ( + run.status !== "COMPLETED" || + run.imported || + !run.outputs?.length || + importing.current.has(run.runId) + ) { + continue; + } + importing.current.add(run.runId); + void importOutputs(run, addFiles).finally(() => + importing.current.delete(run.runId), + ); + } + }, [runs, addFiles]); +} + +/** + * Fetch a completed run's not-yet-imported output files and add them to the + * workspace. Per-output, via allSettled: each output is tracked once imported, + * so a partial failure retries only the missing files on a later tick and the + * ones that succeeded are never added twice. `imported` flips true only once + * every output has landed. + */ +async function importOutputs( + run: PolicyRunRecord, + addFiles: (files: File[]) => Promise, +): Promise { + const done = new Set(run.importedFileIds ?? []); + const pending = run.outputs.filter((out) => !done.has(out.fileId)); + if (pending.length === 0) { + updateRun(run.runId, { imported: true }); + return; + } + + const results = await Promise.allSettled( + pending.map(async (out) => { + const blob = await downloadPolicyOutput(out.fileId); + return { + fileId: out.fileId, + file: new File([blob], out.fileName || run.fileName, { + type: blob.type || "application/pdf", + }), + }; + }), + ); + const fetched = results + .filter( + (r): r is PromiseFulfilledResult<{ fileId: string; file: File }> => + r.status === "fulfilled", + ) + .map((r) => r.value); + if (fetched.length === 0) return; // all failed — retry the lot on a later tick + + // Add the freshly-fetched files, then mark exactly those imported. If addFiles + // throws we don't mark them, so they retry (without having been added). + await addFiles(fetched.map((f) => f.file)); + const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; + updateRun(run.runId, { + importedFileIds, + imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)), + }); +} + +/** + * Resolve the file's bytes, fire a backend run, and record it. Exported so the + * activity feed's Retry action can re-run a policy on a previously-failed file. + */ +export async function runPolicyOnFile( + categoryId: string, + backendId: string, + fileId: FileId, + fileName: string, +): Promise { + // Mark synchronously, before any await, so neither the dispatch effect nor a + // rapid Retry click can double-fire while the file bytes load. + markDispatched(categoryId, fileId); + try { + const file = await fileStorage.getStirlingFile(fileId); + if (!file) return; // file gone; nothing to run (already marked above). + const runId = await runStoredPolicy(backendId, [file]); + recordRunStart({ + runId, + categoryId, + fileId, + fileName, + fileSize: file.size, + status: "PENDING", + outputs: [], + error: null, + startedAt: Date.now(), + }); + } catch { + // Dispatch failed (offline / backend error). Already marked dispatched so we + // don't hammer; the absent run simply won't appear in the activity feed. + } +} + +/** Poll a run's status until it reaches a terminal state (or the cap). */ +async function poll(runId: string): Promise { + for (let i = 0; i < MAX_POLLS; i++) { + await delay(POLL_MS); + let view; + try { + view = await getPolicyRun(runId); + } catch { + continue; // transient — keep trying within the cap. + } + updateRun(runId, { + status: view.status, + outputs: view.outputs, + error: view.error, + }); + if (isTerminal(view.status)) return; + } +} diff --git a/frontend/editor/src/proprietary/constants/featureFlags.ts b/frontend/editor/src/proprietary/constants/featureFlags.ts index fdb5912b4..e6dd00f6e 100644 --- a/frontend/editor/src/proprietary/constants/featureFlags.ts +++ b/frontend/editor/src/proprietary/constants/featureFlags.ts @@ -6,10 +6,17 @@ */ /** - * Watched Folders (a.k.a. Watched Folders) — a proprietary feature whose - * implementation lives under `proprietary/`. Still disabled for now; flip to - * `true` to surface it in the proprietary build only. The core override stays - * `false`, so the shared sidebar entry point never appears in the open-source - * build (which has no Watched Folders implementation to navigate to). + * Watched Folders — a proprietary feature whose implementation lives under + * `proprietary/`. Still disabled for now; flip to `true` to surface it in the + * proprietary build only. The core override stays `false`, so the shared + * sidebar entry point never appears in the open-source build (which has no + * Watched Folders implementation to navigate to). */ export const WATCHED_FOLDERS_ENABLED: boolean = false; + +/** + * Policies — proprietary, automation-backed policy enforcement. Enabled in the + * proprietary build so it's reachable while in active development (frontend is + * mock/stub-backed; no real server yet). Core stays `false`. + */ +export const POLICIES_ENABLED: boolean = true; diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts new file mode 100644 index 000000000..c3e1e6003 --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions"; + +describe("policy definitions integrity", () => { + it("every category has a matching config entry", () => { + for (const cat of POLICY_CATEGORIES) { + expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined(); + expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0); + expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0); + // Every preset seeds a real, non-empty pipeline (the category→steps map). + expect( + POLICY_CONFIG[cat.id].defaultOperations.length, + `defaultOperations for ${cat.id}`, + ).toBeGreaterThan(0); + } + }); + it("field keys are unique within each category", () => { + for (const cat of POLICY_CATEGORIES) { + const keys = POLICY_CONFIG[cat.id].fields.map((f) => f.key); + expect(new Set(keys).size).toBe(keys.length); + } + }); +}); diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx new file mode 100644 index 000000000..5151c2c96 --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -0,0 +1,365 @@ +/** + * Static preset definitions for Policies — the categories, their editable + * settings fields, scope labels, and the default tool pipeline each category + * seeds a new policy with. Runtime activity + stats are derived live from the + * user's real files (see policyLiveData), not defined here. + */ + +import LayersIcon from "@mui/icons-material/Layers"; +import ShieldIcon from "@mui/icons-material/Shield"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import StorageIcon from "@mui/icons-material/Storage"; +import DescriptionIcon from "@mui/icons-material/Description"; +import ComputerIcon from "@mui/icons-material/Computer"; +import PublicIcon from "@mui/icons-material/Public"; +import CloudIcon from "@mui/icons-material/Cloud"; +import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; +import type { + PolicyCategory, + PolicyConfigDef, + PolicySource, +} from "@app/types/policies"; + +const ICON_SX = { fontSize: "1rem" } as const; + +/** The 5 policy categories, in the prototype's narrative order. */ +export const POLICY_CATEGORIES: PolicyCategory[] = [ + { + id: "ingestion", + label: "Ingestion", + icon: , + desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.", + // The classifier the wizard's "Set up Classification" action routes to. + providesClassification: true, + // Needs the classify agent + RAG, which aren't built yet. + comingSoon: true, + }, + { + id: "security", + label: "Security", + icon: , + desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.", + }, + { + id: "compliance", + label: "Compliance", + icon: , + desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.", + comingSoon: true, + }, + { + id: "routing", + label: "Routing", + icon: , + desc: "Auto-route documents to the right team, folder, or system.", + comingSoon: true, + }, + { + id: "retention", + label: "Retention", + icon: , + desc: "Set how long documents are kept, when to archive, and when to delete.", + comingSoon: true, + }, +]; + +/** + * PII presets for the redact step: a label + the regex the /auto-redact endpoint + * matches (via `wordsToRedact` + `useRegex`). Patterns are precise — validated + * (SSN areas, card IINs, ABA prefixes), context- or separator-anchored — to keep + * false positives down and avoid the backtracking the old broad patterns risked. + */ +export const PII_PRESETS: { value: string; label: string; pattern: string }[] = + [ + { + value: "ssn", + label: "Social Security numbers", + // 123-45-6789 or 123 45 6789; rejects invalid areas (000/666/9xx), + // group 00, serial 0000, and mixed separators (backreference). + pattern: + "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", + }, + { + value: "card", + label: "Credit / debit cards", + // Solid runs anchored to real IINs (Visa 13/16, MC 51–55 + 2221–2720, + // Amex 34/37, Discover 6011/65xx) + grouped 4-4-4-4 and Amex 4-6-5 + // with a consistent separator enforced by backreference. + pattern: + "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|(?:222[1-9]|22[3-9]\\d|2[3-6]\\d{2}|27[01]\\d|2720)\\d{12}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12}|[2-6]\\d{3}([ -])\\d{4}\\1\\d{4}\\1\\d{4}|3[47]\\d{2}([ -])\\d{6}\\2\\d{5})\\b", + }, + { + value: "iban", + label: "IBANs", + // Solid (GB29NWBK…) or space-grouped (GB29 NWBK 6016 …) form. + pattern: + "\\b[A-Z]{2}\\d{2}(?:[A-Z0-9]{11,30}|(?: [A-Z0-9]{4}){2,7}(?: [A-Z0-9]{1,4})?)\\b", + }, + { + value: "routing", + label: "US routing numbers (ABA)", + // 9 digits constrained to valid Federal Reserve prefix ranges. + pattern: "\\b(?:0[1-9]|1[0-2]|2[1-9]|3[0-2]|6[1-9]|7[0-2]|80)\\d{7}\\b", + }, + { + value: "account", + label: "Account numbers (labelled)", + // Context-anchored: only digits preceded by Account / Acct / A/C. + pattern: + "\\b(?:[Aa]cc(?:oun)?t|[Aa]/[Cc])(?:\\s+(?:[Nn]o\\.?|[Nn]umber|#))?\\s*[:#]?\\s*\\d{6,17}\\b", + }, + { + value: "email", + label: "Email addresses", + // Requires a real TLD (≥2 letters); won't swallow a sentence-ending period. + pattern: + "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}\\b", + }, + { + value: "phone", + label: "Phone numbers", + // (555) 123-4567 · 555-123-4567 (consistent separator) · +E.164 solid or + // grouped · UK 0-prefixed grouped formats. Bare 10-digit runs excluded. + pattern: + "\\(\\d{3}\\)[ .-]?\\d{3}[ .-]?\\d{4}\\b|\\b\\d{3}([ .-])\\d{3}\\1\\d{4}\\b|\\+\\d{1,3}[ .-]?\\d{6,12}\\b|\\+\\d{1,3}(?:[ .-]\\d{2,4}){2,5}\\b|\\b0\\d{2,4}[ -]\\d{3,4}[ -]?\\d{3,4}\\b", + }, + ]; + +/** + * Defaults seeded into a fresh Security policy's redact step — only the two + * strictest, precise patterns (SSN + cards). Users add the rest (IBAN, routing, + * account, email, phone) from the PII dropdown. + */ +export const DEFAULT_PII_PATTERNS: string[] = [ + PII_PRESETS[0].pattern, // SSN + PII_PRESETS[1].pattern, // cards +]; + +/** Per-category narrative + editable fields (from the prototype's POLICY_CONFIG). */ +export const POLICY_CONFIG: Record = { + ingestion: { + summary: + "Classifies documents, extracts structured data, enforces naming, and normalizes pages.", + rules: ["Classify", "Extract", "Name", "Normalize"], + defaultOperations: [ + { operation: "ocr", parameters: {} }, + { operation: "flatten", parameters: {} }, + ], + scopeLabel: "All PDFs on this device", + // Policy-level controls only — the per-tool params (OCR level, extract + // tables, naming, normalize, rotate…) now live in the Workflow step. + fields: [ + { + label: "Min confidence", + key: "minConfidence", + type: "select", + value: "80%", + options: ["60%", "70%", "80%", "90%", "95%"], + }, + { + label: "Below threshold", + key: "belowThreshold", + type: "select", + value: "Flag for review", + options: ["Flag for review", "Route to bucket", "Hold"], + }, + ], + }, + security: { + summary: + "Detects PII, encrypts, verifies authenticity, controls access, and certifies documents.", + rules: ["Redact PII", "Remove JavaScript"], + // Default chain: redact PII + remove JavaScript (via sanitize) on; watermark + // is offered in the config page but off by default (not seeded here). Redact + // ships with the high-risk PII regexes so it works out of the box. + defaultOperations: [ + { + operation: "redact", + parameters: { + mode: "automatic", + useRegex: true, + wordsToRedact: DEFAULT_PII_PATTERNS, + }, + }, + { operation: "sanitize", parameters: {} }, + ], + scopeLabel: "All PDFs on this device", + // Policy-level controls only — detection/encryption/signing/watermark are + // per-tool and now live in the Workflow step. + fields: [ + { + label: "Default PII response", + key: "defaultResponse", + type: "select", + value: "Highlight & tag", + options: [ + "Highlight & tag", + "Prompt on export", + "Auto-redact on export", + "Block export", + ], + }, + { + label: "User can override", + key: "userOverride", + type: "toggle", + value: true, + }, + { + label: "Default access level", + key: "defaultAccess", + type: "select", + value: "Restricted", + options: ["Open", "Restricted", "Confidential", "Top Secret"], + }, + { + label: "Block external sharing", + key: "blockExternal", + type: "toggle", + value: false, + }, + ], + }, + compliance: { + summary: + "Validates documents against regulatory frameworks before they leave the system.", + rules: ["Framework scan", "Enforce action", "Audit trail"], + defaultOperations: [ + { operation: "sanitize", parameters: {} }, + { operation: "flatten", parameters: {} }, + ], + scopeLabel: "All PDFs on this device", + fields: [ + { + label: "Frameworks", + key: "frameworks", + type: "chips", + value: ["HIPAA"], + options: [ + "HIPAA", + "GDPR", + "SOC 2", + "FedRAMP", + "PCI DSS", + "CCPA", + "ISO 27001", + ], + }, + { + label: "When non-compliant", + key: "onViolation", + type: "select", + value: "Flag for review", + options: [ + "Flag for review", + "Block export", + "Auto-redact PHI", + "Quarantine document", + ], + }, + { label: "Audit trail", key: "auditTrail", type: "toggle", value: true }, + { label: "Access log", key: "accessLog", type: "toggle", value: true }, + ], + }, + routing: { + summary: + "Routes documents to the right destination based on type and classification.", + rules: ["Auto-classify", "Route to folder", "Webhook notify"], + defaultOperations: [{ operation: "compress", parameters: {} }], + scopeLabel: "All PDFs on this device", + fields: [ + { + label: "Destination", + key: "destination", + type: "select", + value: "Documents", + options: ["Documents", "S3 bucket", "SharePoint", "Webhook"], + }, + { label: "Webhook URL", key: "webhookUrl", type: "text", value: "" }, + { label: "Notify on route", key: "notify", type: "toggle", value: false }, + ], + }, + retention: { + summary: + "Enforces how long documents are kept, when to archive, and when to delete.", + rules: ["Retention hold", "Auto-archive", "Deletion block"], + defaultOperations: [{ operation: "compress", parameters: {} }], + scopeLabel: "All PDFs on this device", + fields: [ + { + label: "Keep for", + key: "keepFor", + type: "select", + value: "7 years", + options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"], + }, + { + label: "Archive after", + key: "archiveAfter", + type: "select", + value: "Never", + options: ["30 days", "90 days", "1 year", "Never"], + }, + { + label: "Immutable hold", + key: "immutableHold", + type: "toggle", + value: false, + }, + ], + }, +}; + +/** Sources a policy can run over (wizard step 2). */ +export const POLICY_SOURCES: PolicySource[] = [ + { + id: "editor", + label: "Editor", + desc: "Documents you save or export in Stirling", + icon: , + }, + { + id: "device", + label: "Entire device", + desc: "All PDFs on this machine, retroactively", + icon: , + }, + { + id: "sharepoint", + label: "SharePoint", + desc: "Connected SharePoint libraries", + icon: , + }, + { + id: "dropbox", + label: "Dropbox", + desc: "Connected Dropbox folders", + icon: , + }, + { + id: "gmail", + label: "Gmail", + desc: "PDF attachments in email", + icon: , + }, + { + id: "gdrive", + label: "Google Drive", + desc: "Connected Drive folders", + icon: , + }, +]; + +/** Document types selectable when narrowing scope (gated behind classification). */ +export const POLICY_DOC_TYPES: string[] = [ + "Contracts", + "Invoices", + "Tax documents", + "HR records", + "Insurance", + "Medical / PHI", + "Legal filings", + "Financial reports", +]; diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts new file mode 100644 index 000000000..6563c48b9 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts @@ -0,0 +1,155 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; + +// Enable/delete create + remove the backing Watched Folders WatchedFolder +// (IndexedDB); jsdom's crypto lacks randomUUID, used for folder ids. +if (typeof globalThis.crypto?.randomUUID !== "function") { + const orig = globalThis.crypto; + vi.stubGlobal("crypto", { + getRandomValues: orig?.getRandomValues?.bind(orig), + randomUUID: () => + `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + }); +} + +// In-memory stand-in for the backend policy store, so the hook's persistence +// path is exercised without a real server. +const api = vi.hoisted(() => ({ + store: new Map(), + seq: 0, +})); +vi.mock("@app/services/policyApi", () => ({ + listPolicies: vi.fn(async () => [...api.store.values()]), + savePolicy: vi.fn(async (p: { id?: string }) => { + const id = p.id && p.id.length > 0 ? p.id : `be-${++api.seq}`; + const saved = { ...p, id }; + api.store.set(id, saved); + return saved; + }), + getPolicy: vi.fn(async (id: string) => api.store.get(id)), + deletePolicy: vi.fn(async (id: string) => { + api.store.delete(id); + }), + runStoredPolicy: vi.fn(), + runPolicyPipeline: vi.fn(), + getPolicyRun: vi.fn(), +})); + +import { usePolicies } from "@app/hooks/usePolicies"; + +// A minimal wizard result (workflow already saved + mapped by the builder). +const wizardResult = { + automation: { + id: "auto-1", + name: "Test", + operations: [{ operation: "compress", parameters: {} }], + createdAt: "", + updatedAt: "", + }, + fieldValues: {}, + sources: ["editor"], + scopeTypes: [], + reviewerEmail: "reviewer@x.com", + folder: { + outputMode: "new_file" as const, + outputName: "", + outputNamePosition: "prefix" as const, + maxRetries: 3, + retryDelayMinutes: 5, + }, + pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }], + unresolvedOps: [], +}; + +describe("usePolicies", () => { + beforeEach(() => { + localStorage.clear(); + api.store.clear(); + api.seq = 0; + }); + + it("starts with every category unconfigured (no seed)", async () => { + const { result } = renderHook(() => usePolicies()); + // Flush the async mount reconcile (empty backend ⇒ all stay unconfigured). + await act(async () => {}); + expect(result.current.policies.ingestion.configured).toBe(false); + expect(result.current.policies.security.configured).toBe(false); + }); + + it("enabling a policy persists it to the backend + marks it configured", async () => { + const { result } = renderHook(() => usePolicies()); + await act(async () => { + await result.current.enablePolicy("security", wizardResult); + }); + await waitFor(() => + expect(result.current.policies.security.configured).toBe(true), + ); + expect(result.current.policies.security.status).toBe("active"); + expect(result.current.policies.security.folderId).toBeTruthy(); + expect(result.current.policies.security.backendId).toBeTruthy(); + expect(result.current.policies.security.reviewerEmail).toBe( + "reviewer@x.com", + ); + // The mapped pipeline (endpoint path) reached the backend store. + const stored = [...api.store.values()][0] as unknown as { + steps: unknown[]; + }; + expect(stored.steps).toHaveLength(1); + }); + + it("reconciles configured policies from the backend on mount", async () => { + // Enable on one instance (persists to the backend store)... + const first = renderHook(() => usePolicies()); + await act(async () => { + await first.result.current.enablePolicy("security", wizardResult); + }); + // ...a fresh instance should pick it up from the backend. + const second = renderHook(() => usePolicies()); + await waitFor(() => + expect(second.result.current.policies.security.configured).toBe(true), + ); + expect(second.result.current.policies.security.backendId).toBeTruthy(); + }); + + it("pausing then resuming flips status", async () => { + const { result } = renderHook(() => usePolicies()); + await act(async () => { + await result.current.enablePolicy("ingestion", wizardResult); + }); + await act(async () => { + await result.current.pausePolicy("ingestion"); + }); + expect(result.current.policies.ingestion.status).toBe("paused"); + await act(async () => { + await result.current.resumePolicy("ingestion"); + }); + expect(result.current.policies.ingestion.status).toBe("active"); + }); + + it("deleting a policy reverts it + removes it from the backend", async () => { + const { result } = renderHook(() => usePolicies()); + await act(async () => { + await result.current.enablePolicy("routing", wizardResult); + }); + await waitFor(() => + expect(result.current.policies.routing.configured).toBe(true), + ); + await act(async () => { + await result.current.deletePolicy("routing"); + }); + expect(result.current.policies.routing.configured).toBe(false); + expect(result.current.policies.routing.status).toBe("default"); + expect(result.current.policies.routing.folderId).toBeUndefined(); + expect(result.current.policies.routing.backendId).toBeUndefined(); + expect(api.store.size).toBe(0); + }); + + it("ensurePolicyFolder creates a backing folder for a folderless policy", async () => { + const { result } = renderHook(() => usePolicies()); + await act(async () => { + await result.current.ensurePolicyFolder("ingestion"); + }); + expect(result.current.policies.ingestion.folderId).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts new file mode 100644 index 000000000..f5fee714e --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -0,0 +1,282 @@ +/** + * State + actions for Policies. The backend (`/api/v1/policies`) is the source + * of truth: on mount we reconcile the local cache against the stored policies, + * and every lifecycle action (enable/save/pause/resume/delete) is mirrored to + * the backend. localStorage is a fast-render cache + offline fallback; the + * IndexedDB backing folder still holds the editable automation + run state. + */ + +import { useState, useEffect, useCallback } from "react"; +import { + loadPolicies, + onPoliciesChange, + updatePolicy, + resetPolicy, +} from "@app/services/policyStorage"; +import { loadPolicyCatalog } from "@app/services/policyCatalog"; +import { + createPolicyFolder, + createPolicyFolderForAutomation, + deletePolicyFolder, + getPolicyAutomation, + setPolicyFolderPaused, + updatePolicyFolderSettings, + updatePolicyOperations, +} from "@app/services/policyFolders"; +import { + fetchPoliciesByCategory, + decodedToState, + findBackendId, + persistPolicy, + setPolicyEnabled, + removePolicy, +} from "@app/services/policyBackend"; +import type { PolicyToStore } from "@app/services/policyPipeline"; +import type { + PoliciesByCategory, + PolicyConfigResult, + PolicyWizardResult, +} from "@app/types/policies"; + +/** Build the backend store-request for a category from a wizard result. */ +function toStoreRequest( + categoryId: string, + categoryLabel: string, + result: PolicyWizardResult, + enabled: boolean, + backendId: string | undefined, +): PolicyToStore { + return { + id: backendId, + categoryId, + name: `${categoryLabel} Policy`, + enabled, + automation: result.automation, + pipelineSteps: result.pipelineSteps, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + fieldValues: result.fieldValues, + folder: result.folder, + }; +} + +export function usePolicies() { + const [policies, setPolicies] = useState(loadPolicies); + + useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []); + + // Reconcile the local cache against the backend (the source of truth) on + // mount. Backend config wins; the locally-cached folderId (which the backend + // doesn't track) is preserved. If the backend is unreachable we keep the + // local cache as-is, so the surface still works offline. + useEffect(() => { + let cancelled = false; + void (async () => { + let byCategory; + try { + byCategory = await fetchPoliciesByCategory(); + } catch { + return; // offline / backend down — local cache stands. + } + if (cancelled) return; + const local = loadPolicies(); + const reconciled: PoliciesByCategory = {}; + for (const cat of loadPolicyCatalog().categories) { + const decoded = byCategory.get(cat.id); + reconciled[cat.id] = decoded + ? decodedToState(decoded, local[cat.id]?.folderId) + : { ...local[cat.id], configured: false, status: "default" }; + } + for (const [id, state] of Object.entries(reconciled)) { + updatePolicy(id, state); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + /** + * Enable a new policy from the wizard result: persist it to the backend (the + * source of truth), then create the backing folder holding its editable + * automation, and cache the result locally. Throws (surfacing in the wizard) + * if the category is unknown or the backend save fails. + */ + const enablePolicy = useCallback( + async (id: string, result: PolicyWizardResult) => { + const category = loadPolicyCatalog().categories.find((c) => c.id === id); + if (!category) throw new Error(`Unknown policy category: ${id}`); + // One policy per category, ever: reuse any existing backend record. + const existingBackendId = + loadPolicies()[id]?.backendId ?? + (await findBackendId(id).catch(() => undefined)); + const backendId = await persistPolicy( + toStoreRequest(id, category.label, result, true, existingBackendId), + ); + const folder = await createPolicyFolderForAutomation( + category, + result.automation.id, + ); + await updatePolicyFolderSettings(folder.id, result.folder); + updatePolicy(id, { + configured: true, + status: "active", + folderId: folder.id, + backendId, + fieldValues: result.fieldValues, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + }); + }, + [], + ); + + /** + * Save edits from the wizard. The workflow automation is updated in place by + * the builder; persist the updated policy to the backend and the folder's + * output/retry settings + the rest of the settings locally. + */ + const savePolicyConfig = useCallback( + async (id: string, result: PolicyWizardResult) => { + const current = loadPolicies()[id]; + const category = loadPolicyCatalog().categories.find((c) => c.id === id); + if (!category) throw new Error(`Unknown policy category: ${id}`); + const backendId = await persistPolicy( + toStoreRequest( + id, + category.label, + result, + current?.status !== "paused", + current?.backendId, + ), + ); + if (current?.folderId) { + await updatePolicyFolderSettings(current.folderId, result.folder); + } + updatePolicy(id, { + backendId, + fieldValues: result.fieldValues, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + }); + }, + [], + ); + + /** + * Create-or-update a policy from the locked tool-config page. Unlike the + * wizard path this works straight from the tool `operations` (the config page + * owns the chain): it creates the backing folder + automation on first + * configure, or updates the existing automation's operations on edit, then + * mirrors the whole policy to the backend. One method serves both because a + * preset policy has no separate "create" — you're just configuring it. + */ + const commitPolicyConfig = useCallback( + async (id: string, result: PolicyConfigResult) => { + const category = loadPolicyCatalog().categories.find((c) => c.id === id); + if (!category) throw new Error(`Unknown policy category: ${id}`); + const current = loadPolicies()[id]; + // One policy per category, ever: reuse the existing backend record (even + // if the local link was lost) so a save never creates a duplicate. + const existingBackendId = + current?.backendId ?? (await findBackendId(id).catch(() => undefined)); + let folderId = current?.folderId; + if (folderId) { + await updatePolicyOperations(folderId, result.operations); + } else { + const folder = await createPolicyFolder(category, result.operations); + folderId = folder.id; + } + await updatePolicyFolderSettings(folderId, result.folder); + // The saved automation (with its id) is the lossless round-trip blob. + const automation = await getPolicyAutomation(folderId); + const store: PolicyToStore = { + id: existingBackendId, + categoryId: id, + name: `${category.label} Policy`, + enabled: current?.status !== "paused", + automation: automation ?? { + id: "", + name: `${category.label} Policy`, + operations: result.operations, + createdAt: "", + updatedAt: "", + }, + pipelineSteps: result.pipelineSteps, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + fieldValues: result.fieldValues, + folder: result.folder, + }; + const backendId = await persistPolicy(store); + updatePolicy(id, { + configured: true, + status: current?.status === "paused" ? "paused" : "active", + folderId, + backendId, + fieldValues: result.fieldValues, + sources: result.sources, + scopeTypes: result.scopeTypes, + reviewerEmail: result.reviewerEmail, + }); + }, + [], + ); + + const pausePolicy = useCallback(async (id: string) => { + const current = loadPolicies()[id]; + if (current?.backendId) await setPolicyEnabled(current.backendId, false); + if (current?.folderId) await setPolicyFolderPaused(current.folderId, true); + updatePolicy(id, { status: "paused" }); + }, []); + + const resumePolicy = useCallback(async (id: string) => { + const current = loadPolicies()[id]; + if (current?.backendId) await setPolicyEnabled(current.backendId, true); + if (current?.folderId) await setPolicyFolderPaused(current.folderId, false); + updatePolicy(id, { status: "active" }); + }, []); + + const deletePolicy = useCallback(async (id: string) => { + const current = loadPolicies()[id]; + if (current?.backendId) await removePolicy(current.backendId); + if (current?.folderId) await deletePolicyFolder(current.folderId); + resetPolicy(id); + }, []); + + /** + * Ensure a configured policy has a backing folder (its editable pipeline), + * creating one from the preset if missing. Returns the folder id. + */ + const ensurePolicyFolder = useCallback(async (id: string) => { + const existing = loadPolicies()[id]?.folderId; + if (existing) return existing; + const catalog = loadPolicyCatalog(); + const category = catalog.categories.find((c) => c.id === id); + const config = catalog.configs[id]; + if (!category || !config) return undefined; + const folder = await createPolicyFolder(category, config.defaultOperations); + updatePolicy(id, { folderId: folder.id }); + return folder.id; + }, []); + + // Configuration is open to signed-in users; real per-org gating is a backend + // concern (the mock owner/admin/member permission model has been removed). + const canConfigure = true; + + return { + policies, + canConfigure, + enablePolicy, + savePolicyConfig, + commitPolicyConfig, + pausePolicy, + resumePolicy, + deletePolicy, + ensurePolicyFolder, + }; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts new file mode 100644 index 000000000..9d2bfbfa2 --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts @@ -0,0 +1,16 @@ +import { useMemo } from "react"; +import { + loadPolicyCatalog, + type PolicyCatalog, +} from "@app/services/policyCatalog"; + +/** + * Policy definitions (categories / fields / sources / doc types) delivered + * through the catalog seam, so components never import the static definitions + * directly. Memoised; when the catalog becomes a backend fetch, this hook is + * where loading/error state would be introduced — its consumers already treat + * it as the single source of definitions. + */ +export function usePolicyCatalog(): PolicyCatalog { + return useMemo(() => loadPolicyCatalog(), []); +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts new file mode 100644 index 000000000..5f076225f --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -0,0 +1,43 @@ +import { useMemo } from "react"; +import { usePolicyRuns } from "@app/components/policies/policyRunStore"; +import { loadPolicyCatalog } from "@app/services/policyCatalog"; +import { ROW_ACCENT } from "@app/components/policies/policyStatus"; +import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; + +/** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */ +const ACCENT_VAR: Record = { + blue: "var(--color-blue)", + purple: "var(--color-purple)", + green: "var(--color-green)", + amber: "var(--color-amber)", + red: "var(--color-red)", +}; + +/** + * Distinct policies that have run on each file, keyed by fileId, derived from the + * reactive policy run store. Drives the file sidebar's shield badges. Shadows the + * core stub via the {@code @app/*} alias cascade. + */ +export function usePolicyFileBadges(): Map { + const runs = usePolicyRuns(); + return useMemo(() => { + const labelById = new Map( + loadPolicyCatalog().categories.map((c) => [c.id, c.label]), + ); + const byFile = new Map(); + for (const run of runs) { + const name = labelById.get(run.categoryId); + if (!name) continue; + const list = byFile.get(run.fileId) ?? []; + if (!list.some((p) => p.id === run.categoryId)) { + list.push({ + id: run.categoryId, + name, + accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + }); + byFile.set(run.fileId, list); + } + } + return byFile; + }, [runs]); +} diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts index 4f67f4e20..3e599ab89 100644 --- a/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts +++ b/frontend/editor/src/proprietary/hooks/useWatchedFolders.ts @@ -33,8 +33,9 @@ export function useWatchedFolders(): UseWatchedFoldersReturn { const refreshFolders = useCallback(async () => { try { + // Policy-owned folders are managed by Policies, not shown here. const all = await watchedFolderStorage.getAllFolders(); - setFolders(all); + setFolders(all.filter((f) => !f.policyCategoryId)); } catch (error) { console.error("Failed to load smart folders:", error); } finally { diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts new file mode 100644 index 000000000..0cbcb4964 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -0,0 +1,99 @@ +/** + * Client for the backend Policies engine (`/api/v1/policies`). Runs a + * pipeline on the server — the "backend automation" path — and polls its status. + * Outputs are downloaded via the existing `/api/v1/general/files/{id}` endpoint + * using the file ids in the run view. + */ + +import apiClient from "@app/services/apiClient"; +import type { + BackendPipelineDefinition, + BackendPolicy, + PolicyRunView, +} from "@app/services/policyPipeline"; + +interface JobResponse { + async: boolean; + jobId: string; + result: unknown; +} + +// --- Policy config persistence (server-side store, JPA-backed) --- + +/** Create or update a policy; the backend assigns a blank id and returns it. */ +export async function savePolicy( + policy: BackendPolicy, +): Promise { + const res = await apiClient.post("/api/v1/policies", policy); + return res.data; +} + +/** List all stored policies. */ +export async function listPolicies(): Promise { + const res = await apiClient.get("/api/v1/policies"); + return res.data; +} + +/** Fetch a stored policy by id. */ +export async function getPolicy(id: string): Promise { + const res = await apiClient.get( + `/api/v1/policies/${encodeURIComponent(id)}`, + ); + return res.data; +} + +/** Delete a stored policy by id. */ +export async function deletePolicy(id: string): Promise { + await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`); +} + +/** Run a stored policy by id on the supplied files; returns the run id. */ +export async function runStoredPolicy( + id: string, + files: File[], +): Promise { + const form = new FormData(); + for (const file of files) form.append("fileInput", file); + const res = await apiClient.post( + `/api/v1/policies/${encodeURIComponent(id)}/run`, + form, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return res.data.jobId; +} + +// --- Ad-hoc pipeline runs (no stored policy) --- + +/** + * Run an ad-hoc pipeline on the backend over the given documents. Returns the + * run id; poll {@link getPolicyRun} for status + output file ids. + */ +export async function runPolicyPipeline( + definition: BackendPipelineDefinition, + files: File[], +): Promise { + const form = new FormData(); + for (const file of files) form.append("fileInput", file); + form.append("json", JSON.stringify(definition)); + const res = await apiClient.post("/api/v1/policies/run", form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return res.data.jobId; +} + +/** Download a run's output file by id (via the shared general-files endpoint). */ +export async function downloadPolicyOutput(fileId: string): Promise { + const res = await apiClient.get( + `/api/v1/general/files/${encodeURIComponent(fileId)}`, + { responseType: "blob" }, + ); + return res.data; +} + +/** Current status, step cursor and output files of a run. */ +export async function getPolicyRun(runId: string): Promise { + const res = await apiClient.get( + `/api/v1/policies/run/${encodeURIComponent(runId)}`, + ); + return res.data; +} diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts new file mode 100644 index 000000000..4fd9694d9 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -0,0 +1,95 @@ +/** + * Backend source-of-truth layer for Policies. Wraps the raw `policyApi` client + + * the `policyPipeline` mapper into category-shaped operations the hook can use: + * fetch the stored policies (grouped by catalog category), persist one, flip its + * enabled flag, and delete it. + * + * The frontend is category-keyed (one policy per catalog category); the backend + * is a flat list with assigned ids. The bridge is `trigger.options.categoryId`, + * which `policyPipeline` encodes on save and decodes on read. + */ + +import * as policyApi from "@app/services/policyApi"; +import { + buildBackendPolicy, + fromBackendPolicy, + type DecodedPolicy, + type PolicyToStore, +} from "@app/services/policyPipeline"; +import type { PolicyState } from "@app/types/policies"; + +/** + * Fetch every stored policy and decode it, keyed by its catalog category. If two + * stored policies share a category (shouldn't happen — one per category), the + * last one wins; policies with no recognised categoryId are skipped. + */ +export async function fetchPoliciesByCategory(): Promise< + Map +> { + const stored = await policyApi.listPolicies(); + const byCategory = new Map(); + for (const policy of stored) { + const decoded = fromBackendPolicy(policy); + if (decoded.categoryId) byCategory.set(decoded.categoryId, decoded); + } + return byCategory; +} + +/** + * Project a decoded backend policy onto the frontend per-category state. The + * locally-cached `folderId` (the editable-automation link, which the backend + * doesn't track) is preserved by the caller via `localFolderId`. + */ +export function decodedToState( + decoded: DecodedPolicy, + localFolderId: string | undefined, +): PolicyState { + return { + configured: true, + status: decoded.enabled ? "active" : "paused", + sources: decoded.sources, + scopeTypes: decoded.scopeTypes, + reviewerEmail: decoded.reviewerEmail, + fieldValues: decoded.fieldValues, + folderId: localFolderId, + backendId: decoded.id, + // Catalog-category policies are built-in defaults (not deletable). + isDefault: true, + }; +} + +/** + * The backend id of the stored policy for a category, if one exists. Used to + * enforce one-policy-per-category: a save reuses this id (update) rather than + * creating a duplicate, even if the local cache lost the link. + */ +export async function findBackendId( + categoryId: string, +): Promise { + const byCategory = await fetchPoliciesByCategory(); + return byCategory.get(categoryId)?.id; +} + +/** Persist a policy (create or update); returns the backend-assigned id. */ +export async function persistPolicy(store: PolicyToStore): Promise { + const saved = await policyApi.savePolicy(buildBackendPolicy(store)); + return saved.id; +} + +/** + * Flip a stored policy's `enabled` flag (pause/resume) — the backend gates + * automatic triggering on it. Reads the current policy so the rest of its config + * is preserved on the round-trip. + */ +export async function setPolicyEnabled( + backendId: string, + enabled: boolean, +): Promise { + const current = await policyApi.getPolicy(backendId); + await policyApi.savePolicy({ ...current, enabled }); +} + +/** Delete a stored policy by its backend id. */ +export async function removePolicy(backendId: string): Promise { + await policyApi.deletePolicy(backendId); +} diff --git a/frontend/editor/src/proprietary/services/policyCatalog.ts b/frontend/editor/src/proprietary/services/policyCatalog.ts new file mode 100644 index 000000000..cefe191a8 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyCatalog.ts @@ -0,0 +1,43 @@ +/** + * The catalog of available policy *definitions* — the policy types, their + * editable fields, the sources a policy can run over, and the document types + * scope can be narrowed to. This is the single seam through which definitions + * reach the UI: components never import the static definitions directly, they + * read the catalog (via {@link usePolicyCatalog}). + * + * Backed by the static `policyDefinitions` today; swap {@link loadPolicyCatalog} + * for a fetch to move definitions server-side without touching any component. + */ + +import { + POLICY_CATEGORIES, + POLICY_CONFIG, + POLICY_SOURCES, + POLICY_DOC_TYPES, +} from "@app/data/policyDefinitions"; +import type { + PolicyCategory, + PolicyConfigDef, + PolicySource, +} from "@app/types/policies"; + +export interface PolicyCatalog { + /** Available policy types, in display order. */ + categories: PolicyCategory[]; + /** Per-category narrative + editable field definitions, keyed by category id. */ + configs: Record; + /** Sources a policy can run over (wizard step 2). */ + sources: PolicySource[]; + /** Document types scope can be narrowed to (gated behind classification). */ + docTypes: string[]; +} + +/** Load the policy catalog. Swap this for a backend fetch to go live. */ +export function loadPolicyCatalog(): PolicyCatalog { + return { + categories: POLICY_CATEGORIES, + configs: POLICY_CONFIG, + sources: POLICY_SOURCES, + docTypes: POLICY_DOC_TYPES, + }; +} diff --git a/frontend/editor/src/proprietary/services/policyFolders.test.ts b/frontend/editor/src/proprietary/services/policyFolders.test.ts new file mode 100644 index 000000000..1a34a9414 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyFolders.test.ts @@ -0,0 +1,85 @@ +import "fake-indexeddb/auto"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// jsdom's crypto has no randomUUID, which watchedFolderStorage uses for folder ids. +if (typeof globalThis.crypto?.randomUUID !== "function") { + const orig = globalThis.crypto; + vi.stubGlobal("crypto", { + getRandomValues: orig?.getRandomValues?.bind(orig), + randomUUID: () => + `p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + }); +} + +import { + createPolicyFolder, + getPolicyOperations, + updatePolicyOperations, + setPolicyFolderPaused, + deletePolicyFolder, +} from "@app/services/policyFolders"; +import { watchedFolderStorage } from "@app/services/watchedFolderStorage"; +import { automationStorage } from "@app/services/automationStorage"; +import type { PolicyCategory } from "@app/types/policies"; + +const category: PolicyCategory = { + id: "security", + label: "Security", + icon: null, + desc: "Detect PII, encrypt, verify.", +}; + +const steps = [ + { operation: "sanitize", parameters: {} }, + { operation: "addPassword", parameters: {} }, +]; + +describe("policyFolders backing-folder layer", () => { + beforeEach(async () => { + // Clean slate between tests (fake-indexeddb persists within a run). + for (const f of await watchedFolderStorage.getAllFolders()) { + await watchedFolderStorage.deleteFolder(f.id); + } + for (const a of await automationStorage.getAllAutomations()) { + await automationStorage.deleteAutomation(a.id); + } + }); + + it("creates a folder + automation tagged with the policy category", async () => { + const folder = await createPolicyFolder(category, steps); + expect(folder.policyCategoryId).toBe("security"); + expect(folder.automationId).toBeTruthy(); + + const ops = await getPolicyOperations(folder.id); + expect(ops.map((o) => o.operation)).toEqual(["sanitize", "addPassword"]); + }); + + it("updates the steps through the backing automation", async () => { + const folder = await createPolicyFolder(category, steps); + await updatePolicyOperations(folder.id, [ + { operation: "compress", parameters: {} }, + ]); + const ops = await getPolicyOperations(folder.id); + expect(ops.map((o) => o.operation)).toEqual(["compress"]); + }); + + it("pauses/resumes via the backing folder flag", async () => { + const folder = await createPolicyFolder(category, steps); + await setPolicyFolderPaused(folder.id, true); + expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe( + true, + ); + await setPolicyFolderPaused(folder.id, false); + expect((await watchedFolderStorage.getFolder(folder.id))?.isPaused).toBe( + false, + ); + }); + + it("deletes the folder and its automation", async () => { + const folder = await createPolicyFolder(category, steps); + const automationId = folder.automationId; + await deletePolicyFolder(folder.id); + expect(await watchedFolderStorage.getFolder(folder.id)).toBeNull(); + expect(await automationStorage.getAutomation(automationId)).toBeNull(); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyFolders.ts b/frontend/editor/src/proprietary/services/policyFolders.ts new file mode 100644 index 000000000..7f034f904 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyFolders.ts @@ -0,0 +1,137 @@ +/** + * Backing-folder layer for Policies. A configured policy's folder trigger, + * editable steps, output and run-state all live in a Watched Folders + * {@link WatchedFolder} (+ its {@link AutomationConfig}) — the policy reuses the + * Watched Folders engine rather than re-implementing execution. This module is + * the seam that creates and manages that backing record. + * + * The folder is tagged with `policyCategoryId` so the Watched Folders UI can + * filter it out (it's owned by Policies). The backing automation also rides + * along to the backend (in the saved policy's output.options) for round-trip; + * this folder remains the locally-editable copy. + */ + +import { automationStorage } from "@app/services/automationStorage"; +import { watchedFolderStorage } from "@app/services/watchedFolderStorage"; +import type { + AutomationConfig, + AutomationOperation, +} from "@app/types/automation"; +import type { WatchedFolder } from "@app/types/watchedFolders"; +import type { PolicyCategory, PolicyFolderSettings } from "@app/types/policies"; + +/** Folder icon (a name string) used for each policy category's backing folder. */ +const CATEGORY_FOLDER_ICON: Record = { + ingestion: "StorageIcon", + security: "SecurityIcon", + compliance: "CheckIcon", + routing: "SwapHorizIcon", + retention: "StorageIcon", +}; + +const POLICY_FOLDER_ACCENT = "#3b82f6"; + +/** + * Create the backing folder for a policy: persist an automation from the given + * steps, then a WatchedFolder (the folder trigger) referencing it, tagged with + * the policy's category id. Returns the created folder. + */ +export async function createPolicyFolder( + category: PolicyCategory, + operations: AutomationOperation[], +): Promise { + const automation = await automationStorage.saveAutomation({ + name: `${category.label} Policy`, + description: `Pipeline for the ${category.label} policy`, + operations, + }); + return watchedFolderStorage.createFolder({ + name: `${category.label} Policy`, + description: category.desc, + automationId: automation.id, + icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon", + accentColor: POLICY_FOLDER_ACCENT, + policyCategoryId: category.id, + inputSource: "idb", + }); +} + +/** + * Create the backing folder for a policy from an *already-saved* automation + * (e.g. one the workflow builder just created). Pairs with the wizard, where + * AutomationCreation persists the automation and we link a folder to it. + */ +export async function createPolicyFolderForAutomation( + category: PolicyCategory, + automationId: string, +): Promise { + return watchedFolderStorage.createFolder({ + name: `${category.label} Policy`, + description: category.desc, + automationId, + icon: CATEGORY_FOLDER_ICON[category.id] ?? "WorkIcon", + accentColor: POLICY_FOLDER_ACCENT, + policyCategoryId: category.id, + inputSource: "idb", + }); +} + +/** The policy's current steps, resolved through its backing folder's automation. */ +export async function getPolicyOperations( + folderId: string, +): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (!folder) return []; + const automation = await automationStorage.getAutomation(folder.automationId); + return automation?.operations ?? []; +} + +/** The policy's backing automation (its editable pipeline), via its folder. */ +export async function getPolicyAutomation( + folderId: string, +): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (!folder) return null; + return automationStorage.getAutomation(folder.automationId); +} + +/** Replace the policy's steps by updating its backing automation. */ +export async function updatePolicyOperations( + folderId: string, + operations: AutomationOperation[], +): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (!folder) return; + const automation = await automationStorage.getAutomation(folder.automationId); + if (!automation) return; + await automationStorage.updateAutomation({ ...automation, operations }); +} + +/** Apply output + retry settings to the policy's backing folder. */ +export async function updatePolicyFolderSettings( + folderId: string, + settings: PolicyFolderSettings, +): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (!folder) return; + await watchedFolderStorage.updateFolder({ ...folder, ...settings }); +} + +/** Pause/resume the policy by toggling its backing folder's paused flag. */ +export async function setPolicyFolderPaused( + folderId: string, + paused: boolean, +): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (!folder) return; + await watchedFolderStorage.updateFolder({ ...folder, isPaused: paused }); +} + +/** Delete the policy's backing folder and its automation. */ +export async function deletePolicyFolder(folderId: string): Promise { + const folder = await watchedFolderStorage.getFolder(folderId); + if (folder) { + await automationStorage.deleteAutomation(folder.automationId); + } + await watchedFolderStorage.deleteFolder(folderId); +} diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts new file mode 100644 index 000000000..0a60feeae --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; + +import { + runsToActivity, + runsToStats, + policyActiveFor, +} from "@app/services/policyLiveData"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +function run(over: Partial): PolicyRunRecord { + return { + runId: "r1", + categoryId: "security", + fileId: "f1", + fileName: "f.pdf", + fileSize: 0, + status: "COMPLETED", + outputs: [], + error: null, + startedAt: Date.now(), + ...over, + }; +} + +const HOUR = 3_600_000; + +describe("runsToActivity", () => { + it("maps completed/running/failed runs to activity rows", () => { + const activity = runsToActivity([ + run({ runId: "a", fileName: "fresh.pdf", status: "RUNNING" }), + run({ + runId: "b", + fileName: "contract.pdf", + status: "COMPLETED", + fileSize: 2_100_000, + startedAt: Date.now() - HOUR, + }), + run({ + runId: "c", + fileName: "bad.pdf", + status: "FAILED", + error: "Step 2 failed", + }), + ]); + + expect(activity).toHaveLength(3); + expect(activity[0]).toMatchObject({ + doc: "fresh.pdf", + status: "processing", + action: "Enforcing…", + }); + expect(activity[1]).toMatchObject({ + doc: "contract.pdf", + status: "enforced", + }); + expect(activity[1].action).toContain("2.0 MB"); + expect(activity[2]).toMatchObject({ + doc: "bad.pdf", + status: "flagged", + action: "Step 2 failed", + }); + }); +}); + +describe("runsToStats", () => { + it("counts + sizes only the completed runs", () => { + const stats = runsToStats( + [ + run({ runId: "a", status: "COMPLETED", fileSize: 2_100_000 }), + run({ runId: "b", status: "COMPLETED", fileSize: 900_000 }), + run({ runId: "c", status: "RUNNING", fileSize: 5_000_000 }), + ], + undefined, + ); + expect(stats.enforced).toBe(2); + expect(stats.dataProcessed).toBe("2.9 MB"); + expect(stats.activeFor).toBe("Today"); + }); + + it("derives activeFor from the backing folder's creation time", () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString(); + expect(runsToStats([], fiveDaysAgo).activeFor).toBe("5d"); + }); +}); + +describe("policyActiveFor", () => { + it("returns 'Today' for a just-activated policy", () => { + expect(policyActiveFor(new Date().toISOString())).toBe("Today"); + }); + it("returns 'Today' when there's no backing folder", () => { + expect(policyActiveFor(undefined)).toBe("Today"); + }); + it("reports whole-day duration since activation", () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 86400000).toISOString(); + expect(policyActiveFor(fiveDaysAgo)).toBe("5d"); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts new file mode 100644 index 000000000..48d2ba2b7 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -0,0 +1,100 @@ +/** + * Maps real backend policy runs (from policyRunStore) into the detail view's + * activity feed + summary stats. Runs are produced by the auto-run controller + * firing `/api/v1/policies/{id}/run` on every uploaded file, so the feed is the + * policy's actual enforcement history — not a cosmetic file listing. + */ + +import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +/** Relative "Nm/Nh ago" for an activity timestamp (epoch ms). */ +function relativeTime(ts: number): string { + if (!ts) return "—"; + const mins = Math.floor((Date.now() - ts) / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return days === 1 ? "Yesterday" : `${days}d ago`; +} + +/** Duration since a timestamp, e.g. "18d" / "5h" (no "ago"). */ +function durationSince(ts: number): string { + const ms = Date.now() - ts; + const days = Math.floor(ms / 86400000); + if (days >= 1) return `${days}d`; + const hrs = Math.floor(ms / 3600000); + return hrs >= 1 ? `${hrs}h` : "Today"; +} + +/** + * Human byte size, e.g. "2.1 MB". Intentionally NOT core `formatFileSize`: + * that one renders 2 decimals (`2.13 MB`), whereas the policy summary wants + * the quieter whole-number / single-decimal form used across this surface. + */ +function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + return `${i > 0 && v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; +} + +/** Map a run's lifecycle status to an activity row's display status. */ +function activityStatus(run: PolicyRunRecord): PolicyActivityItem["status"] { + if (run.status === "COMPLETED") return "enforced"; + if (run.status === "FAILED" || run.status === "CANCELLED") return "flagged"; + return "processing"; +} + +function activityAction(run: PolicyRunRecord): string { + switch (activityStatus(run)) { + case "enforced": + return `${formatBytes(run.fileSize)} • enforced`; + case "flagged": + return run.error ?? "Enforcement failed"; + default: + return "Enforcing…"; + } +} + +/** Build the detail view's activity feed from a category's runs (newest first). */ +export function runsToActivity(runs: PolicyRunRecord[]): PolicyActivityItem[] { + return runs.map((run) => ({ + doc: run.fileName, + action: activityAction(run), + time: relativeTime(run.startedAt), + status: activityStatus(run), + runId: run.runId, + fileId: run.fileId, + })); +} + +/** Build the summary stats from a category's runs. */ +export function runsToStats( + runs: PolicyRunRecord[], + folderCreatedAt: string | undefined, +): PolicyStats { + const enforced = runs.filter((r) => r.status === "COMPLETED"); + const bytes = enforced.reduce((sum, r) => sum + (r.fileSize ?? 0), 0); + return { + enforced: enforced.length, + dataProcessed: formatBytes(bytes), + activeFor: policyActiveFor(folderCreatedAt), + }; +} + +/** + * How long a policy has been active — from its backing folder's creation time + * (when it was enabled), or "Today" when there's no folder yet. + */ +export function policyActiveFor(folderCreatedAt: string | undefined): string { + if (!folderCreatedAt) return "Today"; + return durationSince(new Date(folderCreatedAt).getTime()); +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts new file mode 100644 index 000000000..ed4d0a831 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { + buildPipelineDefinition, + buildBackendPolicy, + fromBackendPolicy, +} from "@app/services/policyPipeline"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; + +// Minimal registry: a static-endpoint tool and a function-endpoint tool. +const registry = { + compress: { operationConfig: { endpoint: "/api/v1/misc/compress-pdf" } }, + rotate: { + operationConfig: { + endpoint: (p: Record) => + `/api/v1/general/rotate-pdf?angle=${p.angle}`, + }, + }, +} as unknown as Partial; + +describe("buildPipelineDefinition", () => { + it("maps frontend operations to backend endpoint steps", () => { + const { definition, unresolved } = buildPipelineDefinition( + { + name: "Secure Ingestion", + operations: [ + { operation: "compress", parameters: {} }, + { operation: "rotate", parameters: { angle: 90 } }, + ], + }, + registry, + ); + + expect(unresolved).toEqual([]); + expect(definition.name).toBe("Secure Ingestion"); + expect(definition.output).toEqual({ type: "inline", options: {} }); + expect(definition.steps).toEqual([ + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + { + operation: "/api/v1/general/rotate-pdf?angle=90", + parameters: { angle: 90 }, + }, + ]); + }); + + it("drops + reports operations with no resolvable endpoint", () => { + const { definition, unresolved } = buildPipelineDefinition( + { + name: "X", + operations: [ + { operation: "compress", parameters: {} }, + { operation: "notARealTool", parameters: {} }, + ], + }, + registry, + ); + expect(unresolved).toEqual(["notARealTool"]); + expect(definition.steps).toHaveLength(1); + }); + + it("runs each tool's buildFormData so stored params match its endpoint", () => { + // A redact-like tool whose UI param (wordsToRedact[]) is transformed into + // the endpoint's field (listOfText) by buildFormData — the "marry up". + const redactish = { + redact: { + operationConfig: { + endpoint: () => "/api/v1/security/auto-redact", + buildFormData: ( + p: { wordsToRedact?: string[]; useRegex?: boolean }, + file: File, + ) => { + const fd = new FormData(); + fd.append("fileInput", file); + fd.append("listOfText", (p.wordsToRedact ?? []).join("\n")); + fd.append("useRegex", String(p.useRegex ?? false)); + return fd; + }, + }, + }, + } as unknown as Partial; + + const { definition } = buildPipelineDefinition( + { + name: "Security", + operations: [ + { + operation: "redact", + parameters: { wordsToRedact: ["SSN", "Account"], useRegex: true }, + }, + ], + }, + redactish, + ); + + // The document field is dropped; the UI param became the endpoint field. + expect(definition.steps[0]).toEqual({ + operation: "/api/v1/security/auto-redact", + parameters: { listOfText: "SSN\nAccount", useRegex: "true" }, + }); + }); +}); + +const samplePolicy = { + categoryId: "security", + name: "Security", + enabled: true, + automation: { + id: "auto-1", + name: "Security", + operations: [{ operation: "compress", parameters: {} }], + createdAt: "", + updatedAt: "", + }, + pipelineSteps: [{ operation: "/api/v1/misc/compress-pdf", parameters: {} }], + sources: ["editor"], + scopeTypes: ["Contracts"], + reviewerEmail: "me@x.com", + fieldValues: { minConfidence: "80%" }, + folder: { + outputMode: "new_version" as const, + outputName: "secured", + outputNamePosition: "suffix" as const, + maxRetries: 2, + retryDelayMinutes: 10, + }, +}; + +describe("buildBackendPolicy", () => { + it("maps a frontend policy to the backend Policy shape", () => { + const policy = buildBackendPolicy(samplePolicy); + expect(policy.id).toBe(""); // blank → backend assigns + expect(policy.name).toBe("Security"); + expect(policy.enabled).toBe(true); + expect(policy.steps).toEqual([ + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + ]); + // Extras ride in options. + expect(policy.trigger.options.categoryId).toBe("security"); + expect(policy.trigger.options.reviewerEmail).toBe("me@x.com"); + expect(policy.output.options.maxRetries).toBe(2); + }); + + it("round-trips losslessly through fromBackendPolicy", () => { + const policy = buildBackendPolicy(samplePolicy); + const decoded = fromBackendPolicy({ ...policy, id: "p1" }); + expect(decoded.id).toBe("p1"); + expect(decoded.categoryId).toBe("security"); + expect(decoded.enabled).toBe(true); + expect(decoded.sources).toEqual(["editor"]); + expect(decoded.scopeTypes).toEqual(["Contracts"]); + expect(decoded.reviewerEmail).toBe("me@x.com"); + expect(decoded.fieldValues).toEqual({ minConfidence: "80%" }); + expect(decoded.folder).toEqual(samplePolicy.folder); + expect(decoded.automation?.operations).toEqual( + samplePolicy.automation.operations, + ); + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts new file mode 100644 index 000000000..4b691e988 --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -0,0 +1,291 @@ +/** + * Bridge between the frontend automation model and the backend Policies engine. + * "Backend automation" = send the whole pipeline + files to + * `/api/v1/policies/run` and let the server orchestrate the steps, instead of + * the browser running them one-by-one via executeAutomationSequence. + * + * The backend `PipelineStep.operation` is a tool endpoint *path* + * (e.g. `/api/v1/misc/compress-pdf`) — exactly the `operationConfig.endpoint` + * the frontend tool registry already carries for client-side execution. This + * module maps a frontend AutomationConfig to the backend's PipelineDefinition + * using that registry. + */ + +import type { AutomationConfig } from "@app/types/automation"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { PolicyFolderSettings } from "@app/types/policies"; + +/** A single backend pipeline step: a tool endpoint path + its scalar params. */ +export interface BackendPipelineStep { + operation: string; + parameters: Record; + fileParameters?: Record; +} + +/** Where the run's outputs are delivered. "inline" = return for download. */ +export interface BackendOutputSpec { + type: string; + options: Record; +} + +/** The engine-level pipeline the `/run` endpoint accepts (as JSON). */ +export interface BackendPipelineDefinition { + name: string; + steps: BackendPipelineStep[]; + output: BackendOutputSpec; +} + +/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */ +export interface BackendTriggerConfig { + type: string; + options: Record; +} + +/** A stored, owned policy on the backend (mirrors the Java `Policy` record). */ +export interface BackendPolicy { + /** Blank on create — the backend assigns an id and returns it. */ + id: string; + name: string; + owner: string; + /** Gates automatic triggering; an explicit run ignores it. */ + enabled: boolean; + trigger: BackendTriggerConfig; + steps: BackendPipelineStep[]; + output: BackendOutputSpec; +} + +/** Lifecycle states of a backend run (mirrors PolicyRunStatus). */ +export type PolicyRunStatus = + | "PENDING" + | "RUNNING" + | "WAITING_FOR_INPUT" + | "COMPLETED" + | "FAILED" + | "CANCELLED"; + +export interface BackendResultFile { + fileId: string; + fileName: string; +} + +/** Read-only view returned by the run status endpoint (mirrors PolicyRunView). */ +export interface PolicyRunView { + runId: string; + status: PolicyRunStatus; + currentStep: number; + stepCount: number; + error: string | null; + outputs: BackendResultFile[]; +} + +/** Resolve a frontend operation id to its backend tool endpoint path. */ +function resolveEndpoint( + operation: string, + parameters: Record, + toolRegistry: Partial, +): string | null { + const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig; + const endpoint = config?.endpoint; + if (!endpoint) return null; + const resolved = + typeof endpoint === "function" ? endpoint(parameters) : endpoint; + return resolved ?? null; +} + +/** + * Convert a tool's UI parameters into the exact scalar form-fields its backend + * endpoint expects, by running the same `buildFormData` the client-side runner + * uses (the one source of truth for the request shape) and keeping its non-file + * fields. This is what makes the stored steps "marry up" with the engine: e.g. + * redact's `wordsToRedact: string[]` becomes the `listOfText` string the + * /auto-redact endpoint reads. Falls back to the raw params if the tool has no + * transform (or it throws), so tools without one are unaffected. + */ +function toApiParameters( + config: ToolRegistry[keyof ToolRegistry]["operationConfig"] | undefined, + parameters: Record, +): Record { + const build = config?.buildFormData; + if (typeof build !== "function") return parameters; + const dummy = new File([], "input.pdf", { type: "application/pdf" }); + // buildFormData takes a File (single-file tools) or File[] (multi) — try both. + for (const fileArg of [dummy, [dummy]]) { + try { + const formData = build(parameters, fileArg as never); + const out: Record = {}; + // Keep scalar fields; skip File entries (the document(s) the engine feeds + // separately, and any supporting-file blobs). + formData.forEach((value, key) => { + if (typeof value === "string") out[key] = value; + }); + return out; + } catch { + // Wrong file-arg shape for this tool — try the other, then give up. + } + } + return parameters; +} + +/** + * Map a frontend automation to the backend pipeline definition. Steps whose + * endpoint can't be resolved from the registry are dropped (and reported), so + * the backend never receives an unrunnable operation id. + */ +export function buildPipelineDefinition( + automation: Pick, + toolRegistry: Partial, +): { definition: BackendPipelineDefinition; unresolved: string[] } { + const unresolved: string[] = []; + const steps: BackendPipelineStep[] = []; + for (const op of automation.operations) { + const parameters = (op.parameters ?? {}) as Record; + const endpoint = resolveEndpoint(op.operation, parameters, toolRegistry); + if (!endpoint) { + unresolved.push(op.operation); + continue; + } + const config = + toolRegistry[op.operation as keyof ToolRegistry]?.operationConfig; + steps.push({ + operation: endpoint, + parameters: toApiParameters(config, parameters), + }); + } + return { + definition: { + name: automation.name, + steps, + output: { type: "inline", options: {} }, + }, + unresolved, + }; +} + +/** A frontend policy ready to persist on the backend (the full settings set). */ +export interface PolicyToStore { + /** Existing backend id (blank/omitted → create). */ + id?: string; + /** The frontend catalog category this policy belongs to (1 policy per category). */ + categoryId: string; + name: string; + /** Active (enabled) vs paused/off. */ + enabled: boolean; + /** Full frontend automation, stashed for a lossless UI round-trip. */ + automation: AutomationConfig; + /** + * The engine-runnable steps (endpoint paths), pre-built from `automation` via + * the tool registry by the caller that has it (the wizard). The store layer + * has no registry, so it receives these ready-made. + */ + pipelineSteps: BackendPipelineStep[]; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + fieldValues: Record; + folder: PolicyFolderSettings; +} + +/** The decoded policy read back from the backend. */ +export interface DecodedPolicy { + id: string; + /** The catalog category this policy maps to (from trigger.options.categoryId). */ + categoryId: string; + name: string; + enabled: boolean; + /** Null if the stored policy carried no automation blob. */ + automation: AutomationConfig | null; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + fieldValues: Record; + folder: PolicyFolderSettings; +} + +const DEFAULT_FOLDER: PolicyFolderSettings = { + outputMode: "new_file", + outputName: "", + outputNamePosition: "prefix", + maxRetries: 3, + retryDelayMinutes: 5, +}; + +/** + * Map a frontend policy to the backend {@link BackendPolicy} for persistence. + * The backend models only name/enabled/trigger/steps/output, so the policy-level + * extras (categoryId, sources, scope, reviewer, fields) ride in `trigger.options` + * and the output + retry settings in `output.options`; the full frontend + * automation is stashed in `output.options.automation` for a lossless UI + * round-trip (while `steps` carries the endpoint-mapped pipeline the engine + * runs, pre-built by the caller). + */ +export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { + return { + id: input.id ?? "", + name: input.name, + owner: "", + enabled: input.enabled, + trigger: { + type: "folder", + options: { + categoryId: input.categoryId, + sources: input.sources, + scopeTypes: input.scopeTypes, + reviewerEmail: input.reviewerEmail, + fieldValues: input.fieldValues, + }, + }, + steps: input.pipelineSteps, + output: { + type: "inline", + options: { + mode: input.folder.outputMode, + name: input.folder.outputName, + position: input.folder.outputNamePosition, + maxRetries: input.folder.maxRetries, + retryDelayMinutes: input.folder.retryDelayMinutes, + automation: input.automation, + }, + }, + }; +} + +/** Decode a stored backend policy back into the frontend settings. */ +export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy { + const trigger = policy.trigger.options; + const output = policy.output.options; + const str = (v: unknown, fallback = "") => + typeof v === "string" ? v : fallback; + const num = (v: unknown, fallback: number) => + typeof v === "number" ? v : fallback; + return { + id: policy.id, + categoryId: str(trigger.categoryId), + name: policy.name, + enabled: policy.enabled, + automation: (output.automation as AutomationConfig | undefined) ?? null, + sources: Array.isArray(trigger.sources) + ? (trigger.sources as string[]) + : [], + scopeTypes: Array.isArray(trigger.scopeTypes) + ? (trigger.scopeTypes as string[]) + : [], + reviewerEmail: str(trigger.reviewerEmail), + fieldValues: + (trigger.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, + folder: { + outputMode: output.mode === "new_version" ? "new_version" : "new_file", + outputName: str(output.name), + outputNamePosition: + output.position === "suffix" + ? "suffix" + : output.position === "auto-number" + ? "auto-number" + : "prefix", + maxRetries: num(output.maxRetries, DEFAULT_FOLDER.maxRetries), + retryDelayMinutes: num( + output.retryDelayMinutes, + DEFAULT_FOLDER.retryDelayMinutes, + ), + }, + }; +} diff --git a/frontend/editor/src/proprietary/services/policyStorage.test.ts b/frontend/editor/src/proprietary/services/policyStorage.test.ts new file mode 100644 index 000000000..dd0622a7c --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyStorage.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + loadPolicies, + updatePolicy, + resetPolicy, + onPoliciesChange, +} from "@app/services/policyStorage"; + +describe("policyStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("defaults every category to unconfigured (backend is the source of truth)", () => { + const p = loadPolicies(); + expect(p.ingestion.configured).toBe(false); + expect(p.ingestion.status).toBe("default"); + expect(p.security.configured).toBe(false); + expect(p.security.status).toBe("default"); + expect(p.retention.configured).toBe(false); + }); + + it("persists an update and reflects it on reload", () => { + updatePolicy("security", { configured: true, status: "active" }); + const reloaded = loadPolicies(); + expect(reloaded.security.configured).toBe(true); + expect(reloaded.security.status).toBe("active"); + // Other categories untouched. + expect(reloaded.retention.configured).toBe(false); + }); + + it("merges partial field-value updates without clobbering siblings", () => { + updatePolicy("security", { fieldValues: { detectPII: false } }); + updatePolicy("security", { reviewerEmail: "x@y.com" }); + const p = loadPolicies(); + expect(p.security.fieldValues).toEqual({ detectPII: false }); + expect(p.security.reviewerEmail).toBe("x@y.com"); + }); + + it("resetPolicy reverts a category to unconfigured default", () => { + updatePolicy("compliance", { + configured: true, + status: "active", + reviewerEmail: "a@b.com", + }); + resetPolicy("compliance"); + const p = loadPolicies(); + expect(p.compliance.configured).toBe(false); + expect(p.compliance.status).toBe("default"); + }); + + it("heals missing categories from corrupt/partial storage", () => { + localStorage.setItem( + "stirling-policies-state", + JSON.stringify({ ingestion: { configured: true, status: "active" } }), + ); + const p = loadPolicies(); + // Missing category gets a default rather than being undefined. + expect(p.routing).toBeDefined(); + expect(p.routing.configured).toBe(false); + }); + + it("fires a change event on update", () => { + const cb = vi.fn(); + const off = onPoliciesChange(cb); + updatePolicy("routing", { status: "paused" }); + expect(cb).toHaveBeenCalledTimes(1); + off(); + updatePolicy("routing", { status: "active" }); + expect(cb).toHaveBeenCalledTimes(1); // not called after unsubscribe + }); +}); diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts new file mode 100644 index 000000000..7aa25c23e --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -0,0 +1,111 @@ +/** + * Local cache + offline fallback for Policies — the backend (/api/v1/policies) + * is the source of truth. Holds per-category state (configured/active/paused, + * sources, scope, reviewer, field overrides) and broadcasts changes so hooks + * re-render. Mirrors the change-event pattern of the automation/folder stores. + */ + +import { loadPolicyCatalog } from "@app/services/policyCatalog"; +import type { PoliciesByCategory, PolicyState } from "@app/types/policies"; + +const STORAGE_KEY = "stirling-policies-state"; +export const POLICIES_CHANGE_EVENT = "stirling:policies-changed"; + +function defaultState(): PolicyState { + // Unconfigured by default. The backend is the source of truth for what's + // actually configured + active; this is just the empty local-cache shape. + return { + configured: false, + status: "default", + sources: ["editor"], + scopeTypes: [], + // Empty by default; the wizard defaults the reviewer to the signed-in user. + reviewerEmail: "", + fieldValues: {}, + // Every catalog category is a shipped, built-in policy → default (not + // deletable). User-created policies (later) will set this false. + isDefault: true, + }; +} + +/** An obsolete reviewer email from earlier development, scrubbed from persisted + * state on read so it can re-default to the real signed-in user. */ +const STALE_REVIEWER_EMAIL = "matt@stirlingpdf.com"; + +/** Read the full policy state, seeding + healing any missing categories. */ +export function loadPolicies(): PoliciesByCategory { + let parsed: Partial = {}; + try { + const raw = + typeof localStorage !== "undefined" + ? localStorage.getItem(STORAGE_KEY) + : null; + if (raw) parsed = JSON.parse(raw) as Partial; + } catch { + // Corrupt/unavailable storage — fall back to seed. + } + // Always reconcile against the current category list so a newly-added + // category gets a default rather than being undefined. + const out: PoliciesByCategory = {}; + for (const cat of loadPolicyCatalog().categories) { + const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) }; + // Migration: clear the obsolete persisted reviewer email so it re-defaults + // to the real signed-in user. + if (merged.reviewerEmail === STALE_REVIEWER_EMAIL) + merged.reviewerEmail = ""; + out[cat.id] = merged; + } + return out; +} + +function persist(state: PoliciesByCategory): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } + } catch { + // Best-effort; ignore quota/availability failures. + } + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(POLICIES_CHANGE_EVENT)); + } +} + +/** Merge a partial update into one category's state and persist. */ +export function updatePolicy( + categoryId: string, + patch: Partial, +): PoliciesByCategory { + const current = loadPolicies(); + const next: PoliciesByCategory = { + ...current, + // Fall back to defaults so a not-yet-seeded category id still yields a + // complete PolicyState rather than a partial. + [categoryId]: { + ...defaultState(), + ...current[categoryId], + ...patch, + }, + }; + persist(next); + return next; +} + +/** Reset a category to its unconfigured default (the "Delete policy" action). */ +export function resetPolicy(categoryId: string): PoliciesByCategory { + return updatePolicy(categoryId, { + ...defaultState(), + configured: false, + status: "default", + // Drop the backing-folder + backend links (the caller deletes those). + folderId: undefined, + backendId: undefined, + }); +} + +/** Subscribe to policy-state changes (same-tab). Returns an unsubscribe fn. */ +export function onPoliciesChange(cb: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(POLICIES_CHANGE_EVENT, cb); + return () => window.removeEventListener(POLICIES_CHANGE_EVENT, cb); +} diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts new file mode 100644 index 000000000..979d69625 --- /dev/null +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -0,0 +1,211 @@ +/** + * Types for Policies — a proprietary, automation-backed enforcement feature. + * + * A Policy is conceptually like a Watch Folder (a configured automation that + * runs over documents) but backend-driven and triggered by *sources/events* + * (editor save/export, device sweeps, cloud connectors) rather than just a + * folder. Per-policy state is persisted locally (localStorage) today; server + * persistence and real enforcement land in a follow-up. Activity + stats shown + * in the detail view are derived live from the user's real uploaded files. + */ + +import type { ReactNode } from "react"; +import type { + AutomationConfig, + AutomationOperation, +} from "@app/types/automation"; + +/** Lifecycle status of a policy category for the current user/org. */ +export type PolicyStatus = "default" | "active" | "paused"; + +/** Derived display status for a row/detail (treats a spend-limit hit as paused). */ +export type PolicyRowStatus = "active" | "paused" | "setup"; + +/** A configurable field within a policy's settings. */ +export type PolicyFieldType = "toggle" | "select" | "chips" | "text"; + +export interface PolicyField { + label: string; + key: string; + type: PolicyFieldType; + /** Current value: boolean (toggle), string (select/text), string[] (chips). */ + value: boolean | string | string[]; + /** Options for select/chips. */ + options?: string[]; +} + +/** Static definition of a policy category (the "what it does"). */ +export interface PolicyCategory { + id: string; + label: string; + icon: ReactNode; + /** Long description shown in the setup wizard. */ + desc: string; + /** + * This category provides document classification, so it's the target of the + * setup wizard's "Set up Classification" action. Data-driven replacement for + * the hardcoded `selectPolicy("ingestion")` call site. + */ + providesClassification?: boolean; + /** + * Not yet available — shown as a locked "Coming soon" row that can't be opened + * or configured. Only Security is live today. + */ + comingSoon?: boolean; +} + +/** + * The three-up summary stats shown at the foot of a configured policy's detail, + * derived live from the user's uploaded files (see policyLiveData). + */ +export interface PolicyStats { + /** Documents enforced (rendered with toLocaleString). */ + enforced: number; + /** Human-formatted data-processed figure, e.g. "2.3 GB". */ + dataProcessed: string; + /** Human-formatted active-for figure, e.g. "12d", or "—" when idle. */ + activeFor: string; +} + +/** The narrative + field configuration backing a category. */ +export interface PolicyConfigDef { + /** One-line summary of what the policy enforces. */ + summary: string; + /** Pipeline-like rule chips shown in the "Enforces" section. */ + rules: string[]; + /** Human label for the scope this policy applies to. */ + scopeLabel: string; + /** Editable settings fields. */ + fields: PolicyField[]; + /** + * The preset pipeline this category seeds a new policy with — the real, + * editable tool steps (same shape as the backend's `PipelineStep` and the + * Watch Folders automation `operations`). Configuring a policy starts from + * these and the user can edit them. + */ + defaultOperations: AutomationOperation[]; +} + +/** A document a source can ingest, used in the wizard's "Sources" step. */ +export interface PolicySource { + id: string; + label: string; + desc: string; + icon: ReactNode; +} + +/** An entry in a policy's recent-activity feed (derived from real uploads). */ +export interface PolicyActivityItem { + /** Document the policy acted on. */ + doc: string; + /** What the policy did, e.g. "Classified as Contract • 3 tables extracted". */ + action: string; + /** Relative timestamp, e.g. "2h ago". */ + time: string; + /** + * "enforced" (clean green), "flagged" (needs review, amber), or "processing" + * (in progress — enforcement currently running, blue). + */ + status: "enforced" | "flagged" | "processing"; + /** The backend run this row reflects — used to offer Retry on a failed run. */ + runId?: string; + /** The file the run acted on — needed to re-run it on Retry. */ + fileId?: string; +} + +/** Per-category runtime state held in the local cache. */ +export interface PolicyState { + configured: boolean; + status: PolicyStatus; + /** Selected sources (ids from POLICY_SOURCES). */ + sources: string[]; + /** When non-empty, narrows the policy to these document types. */ + scopeTypes: string[]; + /** Email that low-confidence enforcements are routed to. */ + reviewerEmail: string; + /** Saved field values, keyed by field key (overrides the definition default). */ + fieldValues: Record; + /** + * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that + * holds this policy's editable steps (its automation), output config and run + * state. Present once the policy is configured. The folder trigger reuses the + * Watched Folders engine; this is the link to it. Maps to the backend's + * `Policy.trigger` (folder) + `steps` + `output`. + */ + folderId?: string; + /** + * Id of this policy's record on the backend (the source of truth). Present once + * it has been persisted server-side; used to update/delete/run it. + */ + backendId?: string; + /** + * A built-in policy (one of the shipped catalog categories) rather than a + * user-created one. Default policies are configurable but NOT deletable — the + * Delete action is hidden for them (it returns for custom policies later). + */ + isDefault?: boolean; +} + +export type PoliciesByCategory = Record; + +/** + * Output + retry settings applied by the Watch Folders engine to a policy's + * backing folder (the real, working settings reused from the folder setup). + */ +export interface PolicyFolderSettings { + outputMode: "new_file" | "new_version"; + outputName: string; + outputNamePosition: "prefix" | "suffix" | "auto-number"; + maxRetries: number; + retryDelayMinutes: number; +} + +/** Everything the shared policy wizard collects, handed back on submit. */ +export interface PolicyWizardResult { + /** The saved workflow automation (created on setup, updated in place on edit). */ + automation: AutomationConfig; + fieldValues: Record; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + /** Output + retry settings for the backing folder. */ + folder: PolicyFolderSettings; + /** + * Backend pipeline steps (each `operation` is a tool ENDPOINT path), built + * from the workflow via the tool registry in the wizard's Workflow step — the + * hook persists these to the backend without needing the registry itself. + * Structurally matches policyPipeline's `BackendPipelineStep` (inlined here to + * avoid a types↔services import cycle). + */ + pipelineSteps: { + operation: string; + parameters: Record; + fileParameters?: Record; + }[]; + /** Operation ids whose endpoint couldn't be resolved (dropped from steps). */ + unresolvedOps: string[]; +} + +/** + * What the locked tool-config page hands back on save. Unlike the wizard's + * result it carries the tool `operations` directly (the page owns a fixed, + * configure-only chain — there's no separate saved automation), plus the + * endpoint-mapped pipeline steps. Used for both first-time configure and edits + * of a preset policy. + */ +export interface PolicyConfigResult { + /** The enabled tools (in order) as automation operations. */ + operations: AutomationOperation[]; + /** Endpoint-mapped backend steps built from `operations` via the registry. */ + pipelineSteps: PolicyWizardResult["pipelineSteps"]; + /** Operation ids whose endpoint couldn't be resolved (dropped from steps). */ + unresolvedOps: string[]; + fieldValues: Record; + sources: string[]; + scopeTypes: string[]; + reviewerEmail: string; + folder: PolicyFolderSettings; +} + +/** Which sub-view of a configured policy's detail panel is showing. */ +export type PolicyDetailView = "detail" | "settings"; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 8caadae68..bb672250d 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -203,6 +203,15 @@ export default defineConfig(async ({ mode }) => { }), compressStaticCopyPlugin(), ], + resolve: { + // Global alias so @shared resolves for ALL importers — including the + // shared components' own `@shared/components/X.css` self-imports, which + // live outside the editor tsconfig scope and so aren't rewritten by + // vite-tsconfig-paths. Required for the editor to consume SUI components. + alias: { + "@shared": path.resolve(__dirname, "../shared"), + }, + }, server: { host: true, // make sure this port matches the devUrl port in tauri.conf.json file diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts index 3343f0242..806a4db30 100644 --- a/frontend/editor/vitest.config.ts +++ b/frontend/editor/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; +import path from "node:path"; + +// Global @shared alias so SUI components (and their own `@shared/*` self-imports, +// which live outside the editor tsconfig scope) resolve under test — mirrors the +// resolve.alias in vite.config.ts. +const sharedDir = path.resolve(__dirname, "../shared"); export default defineConfig({ test: { @@ -42,6 +48,9 @@ export default defineConfig({ projects: ["./tsconfig.core.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -60,6 +69,9 @@ export default defineConfig({ projects: ["./tsconfig.proprietary.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -78,6 +90,9 @@ export default defineConfig({ projects: ["./tsconfig.desktop.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -96,6 +111,9 @@ export default defineConfig({ projects: ["./tsconfig.saas.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, @@ -114,6 +132,9 @@ export default defineConfig({ projects: ["./tsconfig.prototypes.vite.json"], }), ], + resolve: { + alias: { "@shared": sharedDir }, + }, esbuild: { target: "es2020", }, diff --git a/frontend/shared/components/Checkbox.css b/frontend/shared/components/Checkbox.css index a9f8aeb4e..5281cdf5e 100644 --- a/frontend/shared/components/Checkbox.css +++ b/frontend/shared/components/Checkbox.css @@ -75,6 +75,12 @@ display: block; } +.sui-check__icon { + display: inline-flex; + align-items: center; + color: var(--color-text-4); + flex-shrink: 0; +} .sui-check__text { display: flex; flex-direction: column; diff --git a/frontend/shared/components/Checkbox.tsx b/frontend/shared/components/Checkbox.tsx index 245242046..7d6be673d 100644 --- a/frontend/shared/components/Checkbox.tsx +++ b/frontend/shared/components/Checkbox.tsx @@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit< > { label?: ReactNode; description?: ReactNode; + /** Optional glyph shown between the box and the label text. */ + leadingIcon?: ReactNode; /** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */ indeterminate?: boolean; } export const Checkbox = forwardRef( function Checkbox( - { label, description, indeterminate, className, id, ...rest }, + { label, description, leadingIcon, indeterminate, className, id, ...rest }, ref, ) { return ( @@ -57,6 +59,11 @@ export const Checkbox = forwardRef( + {leadingIcon && ( + + {leadingIcon} + + )} {(label || description) && ( {label && {label}} diff --git a/frontend/shared/components/ChipFlow.css b/frontend/shared/components/ChipFlow.css new file mode 100644 index 000000000..13eba56df --- /dev/null +++ b/frontend/shared/components/ChipFlow.css @@ -0,0 +1,12 @@ +.sui-chipflow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} +.sui-chipflow__sep { + color: var(--color-text-4); + font-size: 0.75rem; + line-height: 1; + flex-shrink: 0; +} diff --git a/frontend/shared/components/ChipFlow.stories.tsx b/frontend/shared/components/ChipFlow.stories.tsx new file mode 100644 index 000000000..5be1c7238 --- /dev/null +++ b/frontend/shared/components/ChipFlow.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChipFlow } from "@shared/components/ChipFlow"; + +const meta: Meta = { + title: "Primitives/ChipFlow", + component: ChipFlow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + args: { + items: ["Classify", "Extract", "Name", "Normalize"], + separator: "arrow", + tone: "neutral", + size: "sm", + }, + argTypes: { + separator: { control: "inline-radio", options: ["arrow", "none"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Pipeline: Story = { args: { separator: "arrow" } }; +export const Plain: Story = { + args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] }, +}; diff --git a/frontend/shared/components/ChipFlow.tsx b/frontend/shared/components/ChipFlow.tsx new file mode 100644 index 000000000..7415387de --- /dev/null +++ b/frontend/shared/components/ChipFlow.tsx @@ -0,0 +1,47 @@ +import { Fragment } from "react"; +import type { ReactNode } from "react"; +import { Chip } from "@shared/components/Chip"; +import type { ChipTone, ChipSize } from "@shared/components/Chip"; +import "@shared/components/ChipFlow.css"; + +export interface ChipFlowProps { + /** Items rendered as chips, in order. */ + items: ReactNode[]; + /** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */ + separator?: "arrow" | "none"; + tone?: ChipTone; + size?: ChipSize; + className?: string; +} + +/** + * A sequence of {@link Chip}s, optionally joined by arrows to read as a + * pipeline (A → B → C). Use `separator="arrow"` for flows, `none` for a plain + * wrapped chip list. + */ +export function ChipFlow({ + items, + separator = "none", + tone = "neutral", + size = "sm", + className, +}: ChipFlowProps) { + return ( +
+ {items.map((item, i) => ( + + {i > 0 && separator === "arrow" && ( + + → + + )} + + {item} + + + ))} +
+ ); +} diff --git a/frontend/shared/components/DataRow.css b/frontend/shared/components/DataRow.css new file mode 100644 index 000000000..ab5fbf69a --- /dev/null +++ b/frontend/shared/components/DataRow.css @@ -0,0 +1,23 @@ +.sui-datarow { + display: flex; + gap: 0.5rem; +} +.sui-datarow--center { + align-items: center; +} +.sui-datarow--top { + align-items: flex-start; +} +.sui-datarow__label { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-4); +} +.sui-datarow__value { + flex: 1; + min-width: 0; + font-size: 0.75rem; + color: var(--color-text-1); + overflow-wrap: anywhere; +} diff --git a/frontend/shared/components/DataRow.stories.tsx b/frontend/shared/components/DataRow.stories.tsx new file mode 100644 index 000000000..887d8541f --- /dev/null +++ b/frontend/shared/components/DataRow.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DataRow } from "@shared/components/DataRow"; +import { ChipFlow } from "@shared/components/ChipFlow"; +import { Card } from "@shared/components/Card"; + +const meta: Meta = { + title: "Primitives/DataRow", + component: DataRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Single: Story = { + args: { label: "Reviewer", children: "matt@stirlingpdf.com" }, +}; + +export const Summary: Story = { + render: () => ( + +
+ + + + 3 selected + matt@stirlingpdf.com +
+
+ ), +}; diff --git a/frontend/shared/components/DataRow.tsx b/frontend/shared/components/DataRow.tsx new file mode 100644 index 000000000..93a52377e --- /dev/null +++ b/frontend/shared/components/DataRow.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from "react"; +import "@shared/components/DataRow.css"; + +export interface DataRowProps { + /** Left-aligned key. */ + label: ReactNode; + /** Value — text, chips, or any node. */ + children: ReactNode; + /** Fixed key-column width (CSS length). Defaults to 4.5rem. */ + labelWidth?: string; + /** Vertical alignment of label vs value. `top` suits multi-line values. */ + align?: "center" | "top"; + className?: string; +} + +/** + * A key/value row for read-only detail/summary read-outs (a single line of a + * description list). Compose several inside a Card. Use `align="top"` when the + * value wraps (e.g. a chip flow). + */ +export function DataRow({ + label, + children, + labelWidth = "4.5rem", + align = "center", + className, +}: DataRowProps) { + return ( +
+ + {label} + +
{children}
+
+ ); +} diff --git a/frontend/shared/components/IconBadge.css b/frontend/shared/components/IconBadge.css new file mode 100644 index 000000000..2a510b282 --- /dev/null +++ b/frontend/shared/components/IconBadge.css @@ -0,0 +1,35 @@ +.sui-iconbadge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + flex-shrink: 0; +} +.sui-iconbadge--sm { + width: 1.75rem; + height: 1.75rem; +} +.sui-iconbadge--md { + width: 2rem; + height: 2rem; +} +.sui-iconbadge--blue { + color: var(--color-blue); + background: color-mix(in srgb, var(--color-blue) 14%, transparent); +} +.sui-iconbadge--purple { + color: var(--color-purple); + background: color-mix(in srgb, var(--color-purple) 14%, transparent); +} +.sui-iconbadge--green { + color: var(--color-green); + background: color-mix(in srgb, var(--color-green) 14%, transparent); +} +.sui-iconbadge--amber { + color: var(--color-amber); + background: color-mix(in srgb, var(--color-amber) 14%, transparent); +} +.sui-iconbadge--red { + color: var(--color-red); + background: color-mix(in srgb, var(--color-red) 14%, transparent); +} diff --git a/frontend/shared/components/IconBadge.stories.tsx b/frontend/shared/components/IconBadge.stories.tsx new file mode 100644 index 000000000..8ac7f8d39 --- /dev/null +++ b/frontend/shared/components/IconBadge.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { IconBadge } from "@shared/components/IconBadge"; + +function Glyph() { + return ( + + + + + + ); +} + +const meta: Meta = { + title: "Primitives/IconBadge", + component: IconBadge, + tags: ["autodocs"], + parameters: { layout: "centered" }, + args: { accent: "blue", size: "md", children: }, + argTypes: { + accent: { + control: "inline-radio", + options: ["blue", "purple", "green", "amber", "red"], + }, + size: { control: "inline-radio", options: ["sm", "md"] }, + }, +}; +export default meta; +type Story = StoryObj; + +export const Blue: Story = {}; +export const Accents: Story = { + render: () => ( +
+ {(["blue", "purple", "green", "amber", "red"] as const).map((a) => ( + + + + ))} +
+ ), +}; diff --git a/frontend/shared/components/IconBadge.tsx b/frontend/shared/components/IconBadge.tsx new file mode 100644 index 000000000..0968b6459 --- /dev/null +++ b/frontend/shared/components/IconBadge.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import "@shared/components/IconBadge.css"; + +export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red"; + +export interface IconBadgeProps { + children: ReactNode; + /** Tone tint. Defaults to blue. */ + accent?: IconBadgeAccent; + size?: "sm" | "md"; + className?: string; +} + +/** + * A glyph in a rounded, tone-tinted square — the recurring "category icon box" + * motif used by panel headers, summaries and list leads. Centralises the tint + * so every consumer shares one treatment. + */ +export function IconBadge({ + children, + accent = "blue", + size = "md", + className, +}: IconBadgeProps) { + return ( + + {children} + + ); +} diff --git a/frontend/shared/components/ListRow.css b/frontend/shared/components/ListRow.css new file mode 100644 index 000000000..8317b76fc --- /dev/null +++ b/frontend/shared/components/ListRow.css @@ -0,0 +1,88 @@ +.sui-listrow { + display: flex; + align-items: flex-start; + gap: 0.625rem; + width: 100%; + padding: 0.7rem 0.875rem; + text-align: left; + background: transparent; + color: var(--color-text-1); +} +.sui-listrow--divider { + border-top: 1px solid var(--color-border); +} +.sui-listrow--interactive { + cursor: pointer; + transition: background var(--motion-fast); +} +.sui-listrow--interactive:hover { + background: var(--color-bg-hover); +} +.sui-listrow--interactive:focus-visible { + outline: 0.125rem solid var(--color-blue); + outline-offset: -0.125rem; +} + +.sui-listrow__leading { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; + border-radius: var(--radius-md); + flex-shrink: 0; + margin-top: 0.05rem; + color: var(--color-text-4); + background: var(--color-bg-muted); +} +.sui-listrow__leading[data-tone="success"] { + color: var(--color-green); + background: color-mix(in srgb, var(--color-green) 14%, transparent); +} +.sui-listrow__leading[data-tone="warning"] { + color: var(--color-amber); + background: color-mix(in srgb, var(--color-amber) 14%, transparent); +} +.sui-listrow__leading[data-tone="danger"] { + color: var(--color-red); + background: color-mix(in srgb, var(--color-red) 14%, transparent); +} +.sui-listrow__leading[data-tone="info"] { + color: var(--color-blue); + background: color-mix(in srgb, var(--color-blue) 14%, transparent); +} +.sui-listrow__leading[data-tone="purple"] { + color: var(--color-purple); + background: color-mix(in srgb, var(--color-purple) 14%, transparent); +} + +.sui-listrow__text { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} +.sui-listrow__title { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sui-listrow__desc { + font-size: 0.75rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.sui-listrow__meta { + font-size: 0.6875rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.sui-listrow__trailing { + display: inline-flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} diff --git a/frontend/shared/components/ListRow.stories.tsx b/frontend/shared/components/ListRow.stories.tsx new file mode 100644 index 000000000..5aa7f7e97 --- /dev/null +++ b/frontend/shared/components/ListRow.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ListRow } from "@shared/components/ListRow"; +import { Card } from "@shared/components/Card"; +import { StatusBadge } from "@shared/components/StatusBadge"; + +const meta: Meta = { + title: "Primitives/ListRow", + component: ListRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function Dot({ ch }: { ch: string }) { + return {ch}; +} + +export const Default: Story = { + args: { + leading: , + leadingTone: "success", + title: "MSA_Acme_2026.pdf", + description: "Classified as Contract • 3 tables extracted", + meta: "2h ago", + }, +}; + +/** A divided list inside a padding-none Card — the canonical usage. */ +export const InCard: Story = { + render: () => ( + + } + leadingTone="success" + title="MSA_Acme_2026.pdf" + description="Classified as Contract • 3 tables extracted" + meta="2h ago" + /> + } + leadingTone="warning" + title="scan_002.pdf" + description="Low confidence (62%) • flagged for review" + meta="Yesterday" + trailing={ + + Flagged + + } + /> + } + leadingTone="success" + title="Invoice_4471.pdf" + description="Classified as Invoice • renamed to standard" + meta="5h ago" + /> + + ), +}; + +export const Interactive: Story = { + args: { + leading: , + leadingTone: "info", + title: "Clickable row", + description: "The whole row is a button", + onClick: () => {}, + }, +}; diff --git a/frontend/shared/components/ListRow.tsx b/frontend/shared/components/ListRow.tsx new file mode 100644 index 000000000..098df7850 --- /dev/null +++ b/frontend/shared/components/ListRow.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import "@shared/components/ListRow.css"; +import type { StatusTone } from "@shared/components/StatusBadge"; + +export interface ListRowProps { + /** Leading visual (icon/avatar), shown in a tone-tinted square. */ + leading?: ReactNode; + /** Tint for the leading square. Defaults to neutral. */ + leadingTone?: StatusTone; + /** Primary line. */ + title: ReactNode; + /** Secondary line. */ + description?: ReactNode; + /** Tertiary line (e.g. timestamp). */ + meta?: ReactNode; + /** Right-aligned content (badge, chevron, action). */ + trailing?: ReactNode; + /** Makes the whole row a button. */ + onClick?: () => void; + /** Draw a top hairline — set on every row after the first inside a list. */ + divider?: boolean; + className?: string; +} + +/** + * A single list row: a tone-tinted leading glyph + a title/description/meta + * stack + an optional trailing slot. Compose a divided list by wrapping rows in + * a {@code Card padding="none"} and setting {@code divider} on all but the first. + */ +export function ListRow({ + leading, + leadingTone = "neutral", + title, + description, + meta, + trailing, + onClick, + divider, + className, +}: ListRowProps) { + const classes = [ + "sui-listrow", + divider ? "sui-listrow--divider" : "", + onClick ? "sui-listrow--interactive" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + const content = ( + <> + {leading && ( + + {leading} + + )} + + {title} + {description && ( + {description} + )} + {meta && {meta}} + + {trailing && {trailing}} + + ); + + return onClick ? ( + + ) : ( +
{content}
+ ); +} diff --git a/frontend/shared/components/MetricCard.css b/frontend/shared/components/MetricCard.css index 03cc4c154..59c2b4500 100644 --- a/frontend/shared/components/MetricCard.css +++ b/frontend/shared/components/MetricCard.css @@ -17,6 +17,18 @@ color: inherit; text-align: left; } +/* Compact density for narrow rails / metric strips. */ +.sui-metric--sm { + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + min-width: 0; +} +.sui-metric--sm .sui-metric__value { + font-size: 1.125rem; +} +.sui-metric--sm .sui-metric__label { + white-space: nowrap; +} .sui-metric--primary { background: var(--color-bg-subtle); } diff --git a/frontend/shared/components/MetricCard.tsx b/frontend/shared/components/MetricCard.tsx index 9771a9821..1d357932f 100644 --- a/frontend/shared/components/MetricCard.tsx +++ b/frontend/shared/components/MetricCard.tsx @@ -14,6 +14,8 @@ export interface MetricCardProps { deltaDirection?: DeltaDirection; /** Visual emphasis. `primary` = darker surface, used for hero metrics. */ emphasis?: "default" | "primary"; + /** Density. `sm` tightens padding + value size for narrow rails/strips. */ + size?: "sm" | "md"; /** Optional icon shown in the top-right corner. */ icon?: ReactNode; onClick?: () => void; @@ -41,6 +43,7 @@ export function MetricCard({ delta, deltaDirection, emphasis = "default", + size = "md", icon, onClick, className, @@ -50,6 +53,7 @@ export function MetricCard({ const classes = [ "sui-metric", + `sui-metric--${size}`, emphasis === "primary" ? "sui-metric--primary" : "", interactive ? "sui-metric--interactive" : "", className ?? "", diff --git a/frontend/shared/components/NavItem.css b/frontend/shared/components/NavItem.css index d70c11378..982abbc26 100644 --- a/frontend/shared/components/NavItem.css +++ b/frontend/shared/components/NavItem.css @@ -40,8 +40,53 @@ .sui-navitem__trailing { display: inline-flex; align-items: center; + margin-left: auto; } .sui-navitem:focus-visible { outline: 0.125rem solid var(--color-blue); outline-offset: 0.125rem; } + +/* ---- Status accent (optional) ---- */ +.sui-navitem--accent { + position: relative; +} +.sui-navitem--accent::before { + content: ""; + position: absolute; + left: 0; + top: 0.3125rem; + bottom: 0.3125rem; + width: 3px; + border-radius: 3px; +} +.sui-navitem[data-accent="blue"]::before { + background: var(--color-blue); +} +.sui-navitem[data-accent="purple"]::before { + background: var(--color-purple); +} +.sui-navitem[data-accent="green"]::before { + background: var(--color-green); +} +.sui-navitem[data-accent="amber"]::before { + background: var(--color-amber); +} +.sui-navitem[data-accent="red"]::before { + background: var(--color-red); +} +.sui-navitem[data-accent="blue"] .sui-navitem__icon { + color: var(--color-blue); +} +.sui-navitem[data-accent="purple"] .sui-navitem__icon { + color: var(--color-purple); +} +.sui-navitem[data-accent="green"] .sui-navitem__icon { + color: var(--color-green); +} +.sui-navitem[data-accent="amber"] .sui-navitem__icon { + color: var(--color-amber); +} +.sui-navitem[data-accent="red"] .sui-navitem__icon { + color: var(--color-red); +} diff --git a/frontend/shared/components/NavItem.tsx b/frontend/shared/components/NavItem.tsx index fd328bc0f..05826e4b6 100644 --- a/frontend/shared/components/NavItem.tsx +++ b/frontend/shared/components/NavItem.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import "@shared/components/NavItem.css"; +export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red"; + export interface NavItemProps { /** Stable view id passed to the click handler. */ id: string; @@ -8,6 +10,11 @@ export interface NavItemProps { icon?: ReactNode; /** Show the active highlight (navActive background, navActiveText colour). */ isActive?: boolean; + /** + * Optional status accent: draws a left edge bar in the tone colour and tints + * the leading icon to match. For listing live/paused/etc. entities. + */ + accent?: NavItemAccent; /** Optional trailing badge (e.g. unread count, "new"). */ trailing?: ReactNode; onClick?: (id: string) => void; @@ -19,13 +26,14 @@ export interface NavItemProps { * * Active styling: navActive background, navActiveText colour, weight 500. * Hover styling: navHover background, navHoverText colour (only when not - * already active). + * already active). An optional `accent` adds a status edge bar + icon tint. */ export function NavItem({ id, label, icon, isActive, + accent, trailing, onClick, className, @@ -34,9 +42,15 @@ export function NavItem({ )} + {icon && ( + + {icon} + + )}
{title}
{subtitle &&
{subtitle}
} diff --git a/frontend/shared/components/SectionHeader.css b/frontend/shared/components/SectionHeader.css new file mode 100644 index 000000000..63a354cc2 --- /dev/null +++ b/frontend/shared/components/SectionHeader.css @@ -0,0 +1,33 @@ +.sui-sectionhdr { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + padding: 0.4rem 0; + background: transparent; + border: none; + text-align: left; +} +button.sui-sectionhdr { + cursor: pointer; +} +.sui-sectionhdr__title { + flex: 1; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-4); +} +.sui-sectionhdr__count { + font-size: 0.6875rem; + color: var(--color-text-4); +} +.sui-sectionhdr__chevron { + color: var(--color-text-4); + flex-shrink: 0; + transition: transform var(--motion-fast); +} +.sui-sectionhdr__chevron[data-collapsed="true"] { + transform: rotate(-90deg); +} diff --git a/frontend/shared/components/SectionHeader.stories.tsx b/frontend/shared/components/SectionHeader.stories.tsx new file mode 100644 index 000000000..d3adffd1c --- /dev/null +++ b/frontend/shared/components/SectionHeader.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SectionHeader } from "@shared/components/SectionHeader"; + +const meta: Meta = { + title: "Primitives/SectionHeader", + component: SectionHeader, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Static: Story = { args: { title: "Policies", count: "3 active" } }; + +export const Collapsible: Story = { + render: () => { + const [open, setOpen] = useState(true); + return ( + setOpen((v) => !v)} + /> + ); + }, +}; diff --git a/frontend/shared/components/SectionHeader.tsx b/frontend/shared/components/SectionHeader.tsx new file mode 100644 index 000000000..257873093 --- /dev/null +++ b/frontend/shared/components/SectionHeader.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; +import "@shared/components/SectionHeader.css"; + +export interface SectionHeaderProps { + /** Uppercase eyebrow title. */ + title: ReactNode; + /** Optional right-aligned tally/count. */ + count?: ReactNode; + /** Render as a button with a disclosure chevron. */ + collapsible?: boolean; + /** When collapsible, whether the section is expanded. */ + expanded?: boolean; + onToggle?: () => void; + className?: string; +} + +/** + * An uppercase section eyebrow with an optional trailing count and, when + * `collapsible`, a disclosure chevron + toggle. Use above a group of rows/cards. + */ +export function SectionHeader({ + title, + count, + collapsible, + expanded = true, + onToggle, + className, +}: SectionHeaderProps) { + const classes = ["sui-sectionhdr", className ?? ""].filter(Boolean).join(" "); + const inner = ( + <> + {title} + {count != null && {count}} + {collapsible && ( + + + + )} + + ); + + return collapsible ? ( + + ) : ( +
{inner}
+ ); +} diff --git a/frontend/shared/components/Select.css b/frontend/shared/components/Select.css index f97bf0f58..b01cf2ecd 100644 --- a/frontend/shared/components/Select.css +++ b/frontend/shared/components/Select.css @@ -44,6 +44,25 @@ -webkit-appearance: none; } +/* + * The native option popup is OS-rendered: without explicit colours it falls + * back to the UA scheme (white) while the option text inherits the themed + * (light) colour — i.e. white-on-white in dark mode wherever the surrounding + * app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by + * Mantine, not color-scheme). Theme the options explicitly and pin the + * select's color-scheme to the active SUI theme so the popup is always legible. + */ +.sui-select__el option { + background-color: var(--color-surface); + color: var(--color-text-1); +} +[data-theme="dark"] .sui-select__el { + color-scheme: dark; +} +[data-theme="light"] .sui-select__el { + color-scheme: light; +} + .sui-select--sm .sui-select__el { font-size: 0.8125rem; padding-left: var(--space-2); diff --git a/frontend/shared/components/SettingsRow.css b/frontend/shared/components/SettingsRow.css new file mode 100644 index 000000000..f374a5fda --- /dev/null +++ b/frontend/shared/components/SettingsRow.css @@ -0,0 +1,30 @@ +.sui-settingsrow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; +} +.sui-settingsrow__text { + display: flex; + flex-direction: column; + min-width: 0; +} +.sui-settingsrow__label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); +} +.sui-settingsrow__desc { + font-size: 0.75rem; + color: var(--color-text-4); + margin-top: 0.125rem; +} +.sui-settingsrow__control { + flex: 0 0 auto; + min-width: 0; + max-width: 11rem; + display: inline-flex; + align-items: center; + justify-content: flex-end; +} diff --git a/frontend/shared/components/SettingsRow.stories.tsx b/frontend/shared/components/SettingsRow.stories.tsx new file mode 100644 index 000000000..964a92a1e --- /dev/null +++ b/frontend/shared/components/SettingsRow.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsRow } from "@shared/components/SettingsRow"; +import { ToggleSwitch } from "@shared/components/ToggleSwitch"; +import { Select } from "@shared/components/Select"; +import { Card } from "@shared/components/Card"; + +const meta: Meta = { + title: "Primitives/SettingsRow", + component: SettingsRow, + tags: ["autodocs"], + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Toggle: Story = { + args: { + label: "Auto-classify", + control: {}} size="sm" />, + }, +}; + +export const WithDescription: Story = { + args: { + label: "Detect PII", + description: "Scan documents for sensitive fields on save", + control: {}} size="sm" />, + }, +}; + +/** A settings list inside a padding-none Card. */ +export const List: Story = { + render: () => ( + + {[ + { label: "Auto-classify", on: true }, + { label: "Extract tables", on: true }, + { label: "Strip blank pages", on: false }, + ].map((r, i) => ( +
0 ? "1px solid var(--color-border)" : undefined, + }} + > + {}} size="sm" /> + } + /> +
+ ))} +
+ ), +}; + +export const SelectControl: Story = { + args: { + label: "OCR level", + control: ( + setReviewerEmail(e.target.value)} - placeholder="email@company.com" - /> - - - -

Summary

- -
- - {category.icon} - - - {category.label} Policy - -
-
- - - - {SOURCES_IN_FLOW && ( - {sources.length} selected - )} - - {reviewerEmail || Not set} - -
-
- - )}
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx index 497cad780..ba8a3842f 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyToolConfig.tsx @@ -2,10 +2,22 @@ import { Suspense } from "react"; import { Loader } from "@mantine/core"; import { ToggleSwitch } from "@shared/components/ToggleSwitch"; import { Card } from "@shared/components/Card"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; import type { ToolRegistry } from "@app/data/toolsTaxonomy"; import type { ToolId } from "@app/types/toolId"; +/** Plain-language, non-technical descriptions shown by each tool's info button. */ +const TOOL_PLAIN_INFO: Record = { + redact: + "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.", + sanitize: + "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.", + watermark: "Stamps a visible mark (e.g. “Confidential”) across every page.", +}; + /** One tool in a policy's fixed chain: whether it runs + its configured params. */ export interface PolicyToolState { /** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */ @@ -54,6 +66,26 @@ export function PolicyToolConfig({ {entry?.name ?? tool.operation} + {TOOL_PLAIN_INFO[tool.operation] && ( + + + + )} {tool.enabled && (tool.operation === "redact" ? ( - // Redact has a bespoke config: PII preset dropdown + a custom - // word/regex field + advanced options, mode + regex locked on. + // Redact config is reduced to just the PII type picker.
+ ) : tool.operation === "sanitize" ? ( + // Sanitize is config-less: it only removes JavaScript (params + // are fixed in the policy preset), so no settings are shown. + <> + ) : tool.operation === "watermark" ? ( + // Watermark: full settings minus the "Flatten PDF pages to + // images" toggle (hidden), with flatten forced on. +
+ patchTool(index, { parameters })} + disabled={!editable} + /> +
) : Settings ? (
}> diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx new file mode 100644 index 000000000..692508a92 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/AddWatermarkSingleStepSettings"; +import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; + +interface PolicyWatermarkConfigProps { + parameters: Record; + onChange: (parameters: Record) => void; + disabled?: boolean; +} + +/** + * Watermark configuration for a policy: the full watermark settings minus the + * "Flatten PDF pages to images" toggle (hidden), with flatten forced on so the + * watermark is baked into the page and can't be stripped out. Normalised once + * on mount. + */ +export function PolicyWatermarkConfig({ + parameters, + onChange, + disabled, +}: PolicyWatermarkConfigProps) { + useEffect(() => { + if (parameters.convertPDFToImage !== true) { + onChange({ ...parameters, convertPDFToImage: true }); + } + }, []); + + return ( + + onChange({ ...parameters, [key]: value }) + } + disabled={disabled} + showFlatten={false} + /> + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 77eb931b5..434b84dfa 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -26,6 +26,10 @@ export interface PolicyRunRecord { /** Output fileIds already imported — tracked per-file so a partial failure * retries only the missing ones and never re-adds the ones that succeeded. */ importedFileIds?: string[]; + /** Workspace fileIds of the imported output files (the versioned child for + * "new version", or the added file for "new file"). Drives the policy badge, + * which marks the policy's OUTPUT — not the input it ran on. */ + outputFileIds?: string[]; error: string | null; /** Epoch ms when the run was dispatched. */ startedAt: number; diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index a9407a6d0..4cfc537f4 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -12,7 +12,11 @@ */ import { useEffect, useRef } from "react"; -import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileManagement, + useFileContext, +} from "@app/contexts/FileContext"; import { fileStorage } from "@app/services/fileStorage"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; import { @@ -22,8 +26,11 @@ import { } from "@app/services/policyApi"; import type { PolicyRunStatus } from "@app/services/policyPipeline"; import type { FileId } from "@app/types/file"; +import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import { usePolicies } from "@app/hooks/usePolicies"; import { + dispatchKey, isDispatched, markDispatched, recordRunStart, @@ -36,6 +43,12 @@ import { const POLL_MS = 2000; const MAX_POLLS = 75; +/** How long to wait for an upload's bytes to land in IndexedDB before giving up + * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its + * bytes are committed, so a too-eager fetch would otherwise miss the file. */ +const FILE_WAIT_TRIES = 20; +const FILE_WAIT_MS = 250; + function isTerminal(status: PolicyRunStatus): boolean { return ( status === "COMPLETED" || status === "FAILED" || status === "CANCELLED" @@ -47,11 +60,14 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export function usePolicyAutoRun(): void { const { fileStubs } = useAllFiles(); const { addFiles } = useFileManagement(); + const { consumeFiles } = useFileContext(); const { policies } = usePolicies(); const runs = usePolicyRuns(); - // Run ids currently being polled / imported, so the effects never double-fire. + // Keys (run ids / dispatch keys) currently in flight, so the effects never + // double-fire across re-renders while their first async step is pending. const polling = useRef>(new Set()); const importing = useRef>(new Set()); + const dispatching = useRef>(new Set()); // Dispatch: for each active policy × each session file not yet run, fire a run. useEffect(() => { @@ -61,14 +77,25 @@ export function usePolicyAutoRun(): void { ); for (const [categoryId, s] of active) { for (const stub of fileStubs) { - if (isDispatched(categoryId, stub.id)) continue; - // runPolicyOnFile marks dispatched synchronously before its first await. + const key = dispatchKey(categoryId, stub.id); + // Skip if already run (persisted) or a dispatch is mid-flight (this + // session) — runPolicyOnFile now waits for the file's bytes to commit, + // so the in-memory guard, not an eager mark, prevents double-firing. + if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) { + continue; + } + dispatching.current.add(key); void runPolicyOnFile( categoryId, s.backendId as string, stub.id, stub.name, - ); + ) + .catch(() => { + // runPolicyOnFile handles its own failures; this is just a backstop + // so an unexpected rejection never becomes an unhandled rejection. + }) + .finally(() => dispatching.current.delete(key)); } } }, [fileStubs, policies]); @@ -97,23 +124,53 @@ export function usePolicyAutoRun(): void { continue; } importing.current.add(run.runId); - void importOutputs(run, addFiles).finally(() => - importing.current.delete(run.runId), - ); + // Honour the policy's output mode: a new file, or a new version of the + // input file it ran on (needs that input's stub, still in the workspace). + const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; + const outputName = policies[run.categoryId]?.outputName ?? ""; + const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); + void importOutputs(run, { + addFiles, + consumeFiles, + outputMode, + outputName, + parentStub, + }).finally(() => importing.current.delete(run.runId)); } - }, [runs, addFiles]); + }, [runs, addFiles, consumeFiles, policies, fileStubs]); +} + +interface ImportContext { + addFiles: (files: File[]) => Promise; + consumeFiles: ( + inputFileIds: FileId[], + outputs: StirlingFile[], + stubs: StirlingFileStub[], + ) => Promise; + /** "new_file" adds the output as a separate file; "new_version" versions the input. */ + outputMode: "new_file" | "new_version"; + /** Rename rule. Empty → keep the input's filename; set → use the policy's + * renamed output (applied server-side per the name-position setting). */ + outputName: string; + /** The input file's stub — required to version it; absent if it's been removed. */ + parentStub: StirlingFileStub | undefined; } /** - * Fetch a completed run's not-yet-imported output files and add them to the - * workspace. Per-output, via allSettled: each output is tracked once imported, + * Fetch a completed run's not-yet-imported output files and deliver them to the + * workspace. Per-output, via allSettled: each output is tracked once delivered, * so a partial failure retries only the missing files on a later tick and the * ones that succeeded are never added twice. `imported` flips true only once * every output has landed. + * + * Delivery honours the policy's output mode: "new_version" replaces the input + * file with a versioned child (its history chain), "new_file" adds the output + * as a standalone file. Versioning falls back to a new file if the input is + * gone (no parent stub). */ async function importOutputs( run: PolicyRunRecord, - addFiles: (files: File[]) => Promise, + ctx: ImportContext, ): Promise { const done = new Set(run.importedFileIds ?? []); const pending = run.outputs.filter((out) => !done.has(out.fileId)); @@ -122,12 +179,18 @@ async function importOutputs( return; } + // Keep the input's original filename unless a rename rule is set — without a + // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would + // otherwise rename every output. + const targetName = ctx.outputName + ? undefined // use the run's per-output (renamed) name below + : run.fileName; const results = await Promise.allSettled( pending.map(async (out) => { const blob = await downloadPolicyOutput(out.fileId); return { fileId: out.fileId, - file: new File([blob], out.fileName || run.fileName, { + file: new File([blob], targetName ?? out.fileName ?? run.fileName, { type: blob.type || "application/pdf", }), }; @@ -141,12 +204,38 @@ async function importOutputs( .map((r) => r.value); if (fetched.length === 0) return; // all failed — retry the lot on a later tick - // Add the freshly-fetched files, then mark exactly those imported. If addFiles - // throws we don't mark them, so they retry (without having been added). - await addFiles(fetched.map((f) => f.file)); + // Deliver, then mark exactly those imported. If delivery throws we don't mark + // them, so they retry (without having been added). + const files = fetched.map((f) => f.file); + // Workspace fileIds of the delivered outputs — the policy badge marks these + // (the policy's output), not the input it ran on. Set in both branches below. + let deliveredIds: string[]; + if (ctx.outputMode === "new_version" && ctx.parentStub) { + // Replace the input file with a versioned child (preserves its history). + // The version records "automate" as its origin tool — a policy is a + // multi-tool automation, not any single tool (redact/watermark/sanitize/…). + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + files, + ctx.parentStub, + "automate", + ); + // Mark the outputs handled BEFORE adding them, so the auto-run never enforces + // the policy on its own output — that would version endlessly in a loop. + for (const s of stubs) markDispatched(run.categoryId, s.id); + deliveredIds = stubs.map((s) => s.id as string); + await ctx.consumeFiles([run.fileId as FileId], stirlingFiles, stubs); + } else { + const added = await ctx.addFiles(files); + // Same loop-guard for new-file output: the produced file is a new workspace + // file the auto-run would otherwise re-enforce indefinitely. + for (const f of added) markDispatched(run.categoryId, f.fileId); + deliveredIds = added.map((f) => f.fileId as string); + } const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; updateRun(run.runId, { importedFileIds, + // Accumulate across partial-import retries rather than overwriting. + outputFileIds: [...(run.outputFileIds ?? []), ...deliveredIds], imported: run.outputs.every((out) => importedFileIds.includes(out.fileId)), }); } @@ -161,13 +250,33 @@ export async function runPolicyOnFile( fileId: FileId, fileName: string, ): Promise { - // Mark synchronously, before any await, so neither the dispatch effect nor a - // rapid Retry click can double-fire while the file bytes load. - markDispatched(categoryId, fileId); + // A freshly-uploaded file's bytes are written to IndexedDB asynchronously, so + // its stub can appear in the file list a beat before getStirlingFile resolves + // it. Wait briefly rather than bail — and DON'T mark dispatched until we hold + // the file, or a too-early miss would skip enforcement on that file forever. + // (The caller's in-flight guard prevents double-dispatch during this wait.) + // A transient IndexedDB error is treated as a miss (not a throw), so it retries + // and then marks dispatched rather than rejecting into a hot re-dispatch loop. + const tryGetFile = async (): Promise => { + try { + return await fileStorage.getStirlingFile(fileId); + } catch { + return null; + } + }; + let file = await tryGetFile(); + for (let i = 0; i < FILE_WAIT_TRIES && !file; i++) { + await delay(FILE_WAIT_MS); + file = await tryGetFile(); + } + if (!file) { + // File genuinely gone (removed before it could run) — mark so we don't loop. + markDispatched(categoryId, fileId); + return; + } try { - const file = await fileStorage.getStirlingFile(fileId); - if (!file) return; // file gone; nothing to run (already marked above). const runId = await runStoredPolicy(backendId, [file]); + // recordRunStart marks this (policy, file) dispatched as it records the run. recordRunStart({ runId, categoryId, @@ -180,8 +289,9 @@ export async function runPolicyOnFile( startedAt: Date.now(), }); } catch { - // Dispatch failed (offline / backend error). Already marked dispatched so we - // don't hammer; the absent run simply won't appear in the activity feed. + // Dispatch failed (offline / backend error). Mark dispatched so we don't + // hammer; the absent run simply won't appear in the activity feed. + markDispatched(categoryId, fileId); } } diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 5151c2c96..07601a0d3 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -180,27 +180,29 @@ export const POLICY_CONFIG: Record = { parameters: { mode: "automatic", useRegex: true, + // Flatten to image so redacted text is truly removed, not just hidden + // behind a box (heavier, but real redaction). + convertPDFToImage: true, wordsToRedact: DEFAULT_PII_PATTERNS, }, }, - { operation: "sanitize", parameters: {} }, + { + // Sanitize is fixed to JavaScript removal only (no per-policy config). + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, + removeXMPMetadata: false, + }, + }, ], scopeLabel: "All PDFs on this device", // Policy-level controls only — detection/encryption/signing/watermark are // per-tool and now live in the Workflow step. fields: [ - { - label: "Default PII response", - key: "defaultResponse", - type: "select", - value: "Highlight & tag", - options: [ - "Highlight & tag", - "Prompt on export", - "Auto-redact on export", - "Block export", - ], - }, { label: "User can override", key: "userOverride", diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index f5fee714e..8e954b23f 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -128,6 +128,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -161,6 +163,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -222,6 +226,8 @@ export function usePolicies() { sources: result.sources, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, + outputMode: result.folder.outputMode, + outputName: result.folder.outputName, }); }, [], @@ -249,17 +255,35 @@ export function usePolicies() { }, []); /** - * Ensure a configured policy has a backing folder (its editable pipeline), - * creating one from the preset if missing. Returns the folder id. + * Ensure a configured policy has a *valid* backing folder (its editable + * pipeline) and return its id. Self-heals a stale `folderId` — one that no + * longer resolves to a real folder (cleared storage, or a folder left in an + * old IndexedDB after a rename/migration) — which would otherwise hang the + * Edit-Settings view on a permanent "Loading…". When recreating, the backend's + * stored automation is used if present so the configured pipeline survives; + * otherwise it falls back to the preset. */ const ensurePolicyFolder = useCallback(async (id: string) => { - const existing = loadPolicies()[id]?.folderId; - if (existing) return existing; + const state = loadPolicies()[id]; + const existing = state?.folderId; + // A healthy backing folder resolves to an automation; if it does, keep it. + if (existing && (await getPolicyAutomation(existing))) return existing; const catalog = loadPolicyCatalog(); const category = catalog.categories.find((c) => c.id === id); const config = catalog.configs[id]; if (!category || !config) return undefined; - const folder = await createPolicyFolder(category, config.defaultOperations); + // Stale/missing folder → recreate. Prefer the backend's stored automation + // (preserves the user's configured steps); else seed from the preset. + let operations = config.defaultOperations; + if (state?.backendId) { + const decoded = await fetchPoliciesByCategory() + .then((m) => m.get(id)) + .catch(() => undefined); + if (decoded?.automation?.operations?.length) { + operations = decoded.automation.operations; + } + } + const folder = await createPolicyFolder(category, operations); updatePolicy(id, { folderId: folder.id }); return folder.id; }, []); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 5f076225f..6afc43a48 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -14,9 +14,11 @@ const ACCENT_VAR: Record = { }; /** - * Distinct policies that have run on each file, keyed by fileId, derived from the - * reactive policy run store. Drives the file sidebar's shield badges. Shadows the - * core stub via the {@code @app/*} alias cascade. + * Distinct policies that have produced each file, keyed by fileId, derived from + * the reactive policy run store. Drives the file sidebar's shield badges. The + * badge marks a policy's OUTPUT (the versioned/added result), not the input it + * ran on — so it keys off each run's imported output fileIds. Shadows the core + * stub via the {@code @app/*} alias cascade. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); @@ -28,14 +30,16 @@ export function usePolicyFileBadges(): Map { for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; - const list = byFile.get(run.fileId) ?? []; - if (!list.some((p) => p.id === run.categoryId)) { - list.push({ - id: run.categoryId, - name, - accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], - }); - byFile.set(run.fileId, list); + for (const fileId of run.outputFileIds ?? []) { + const list = byFile.get(fileId) ?? []; + if (!list.some((p) => p.id === run.categoryId)) { + list.push({ + id: run.categoryId, + name, + accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + }); + byFile.set(fileId, list); + } } } return byFile; diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 4fd9694d9..77ff6ecbd 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -51,6 +51,8 @@ export function decodedToState( scopeTypes: decoded.scopeTypes, reviewerEmail: decoded.reviewerEmail, fieldValues: decoded.fieldValues, + outputMode: decoded.folder.outputMode, + outputName: decoded.folder.outputName, folderId: localFolderId, backendId: decoded.id, // Catalog-category policies are built-in defaults (not deletable). diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index b3b088ecd..abbd28dd9 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -203,7 +203,7 @@ export interface DecodedPolicy { } const DEFAULT_FOLDER: PolicyFolderSettings = { - outputMode: "new_file", + outputMode: "new_version", outputName: "", outputNamePosition: "prefix", maxRetries: 3, @@ -265,7 +265,10 @@ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy { fieldValues: (output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, folder: { - outputMode: output.mode === "new_version" ? "new_version" : "new_file", + // Default to versioning unless the stored policy explicitly says new_file, + // so a missing/legacy output.mode follows the new-version default rather + // than silently flipping a reconciled policy to spawning separate files. + outputMode: output.mode === "new_file" ? "new_file" : "new_version", outputName: str(output.name), outputNamePosition: output.position === "suffix" diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index 7aa25c23e..87616555c 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -22,6 +22,10 @@ function defaultState(): PolicyState { // Empty by default; the wizard defaults the reviewer to the signed-in user. reviewerEmail: "", fieldValues: {}, + // Default to versioning the input file rather than spawning a separate one. + outputMode: "new_version", + // No rename by default — the output keeps the input's filename. + outputName: "", // Every catalog category is a shipped, built-in policy → default (not // deletable). User-created policies (later) will set this false. isDefault: true, diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index 979d69625..7cc5dc27c 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -125,6 +125,13 @@ export interface PolicyState { reviewerEmail: string; /** Saved field values, keyed by field key (overrides the definition default). */ fieldValues: Record; + /** How a run's output is delivered: a separate new file, or a new version of + * the input file the policy ran on. Defaults to "new_version". */ + outputMode?: "new_file" | "new_version"; + /** Rename rule for the output. When empty (the default) the output keeps the + * input's filename; when set, it's applied as a prefix/suffix per the policy's + * name-position setting. */ + outputName?: string; /** * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that * holds this policy's editable steps (its automation), output config and run From cf513c255baf551b7c29fb713b69f1554ff21fff Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:56:01 +0100 Subject: [PATCH 34/80] =?UTF-8?q?PAYG:=20pay-as-you-go=20billing=20?= =?UTF-8?q?=E2=80=94=20metered=20automation/AI/API=20+=20one-time=20free?= =?UTF-8?q?=20grant=20(#6589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing stays free forever; only **automation, AI, and API** usage is metered. Every team gets a **one-time lifetime free grant** (default 500 PDFs) before any billing; past that, a team adds a card and pays per metered document, with a self-set monthly spending cap. This branch combines and supersedes the in-flight BE (#6574) and FE (#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on `v3`), hardened into a single reviewable feature after a pre-merge dead-code/security review. ## Billing model - **Always free:** manual / JWT web-tool usage is `BYPASSED` — never metered, no matter where it's triggered. - **Billable categories:** `AUTOMATION`, `AI`, `API`. - **One-time lifetime free grant** (`pricing_policy.free_tier_units`, default 500): never resets, survives subscribing. It gates unsubscribed teams (billable API calls hard-stop with a 402 once exhausted) and decides the free-vs-paid split of every job. - **Subscribed:** paid documents (beyond the grant) are metered to a Stripe Billing Meter; an optional monthly spending cap degrades billable categories when reached. - **Dedup:** the same file pushed through several steps within a workflow window counts **once** (lineage join), so API/AI chaining on one file isn't double-charged. ## What's included **Database** — Flyway migrations `V11`→`V21` with matching Supabase twins: pricing policy + per-team sidecar (`payg_team_extensions`: subscription id, Stripe customer, free-grant counter), append-only `wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit logs (`V15`), billing category (`V16`), one-time lifetime free grant (`V19`), launch-grant seed (`V20`), drop of the unused `wallet_category_summary` view (`V21`). **Charge pipeline** — `PaygChargeInterceptor` (open/join a process, split the free grant, write the ledger DEBIT), `JobChargeService` (consume the grant under a row lock, restore it on a first-step refund, meter only the paid portion on completion), `StaleJobCloser` fallback (idempotent close → meter). **Entitlement** — `EntitlementService` (per-team cached snapshot: grant-gated for free teams, monthly-cap-gated for subscribed) + `EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` / `PAYG_LIMIT_REACHED`). **Metering** — `PaygMeterReportingService` writes a durable `payg_meter_event_log` row around every POST to the `meter-payg-units` edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries unposted events under the same idempotency key inside Stripe's 24h dedup window. **Billing facts** — `TeamBillingService` reads the synced `stripe.*` mirror (subscription window, per-document rate; the unsubscribed-team estimate resolves the rate by Price `lookup_key = plan:processor`). **Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`, `PATCH /api/v1/payg/cap`. **Frontend** — PAYG Plan page (two-card free layout + subscribed views), `useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and a shared `SpendCapControl`, customer-portal link, 402/401 interceptor toast, en-GB i18n. (Per-member usage shows each teammate's spend; the activity feed is behind a flag until polished.) **SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) — `create-checkout-session`, `create-payg-team-subscription`, `create-customer-portal-session`, `meter-payg-units`, `payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync `migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no `STRIPE_PAYG_PRICE_ID_*` env vars). ## Release prerequisites (prod) 1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration twins. 2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped** backfill — `product`, `price`, `customer`, `subscription` only (not `all`, which rate-limits). 3. Register 2 PAYG webhook endpoints (each its own signing secret): `stripe-sync` (product/price/customer/subscription `.*`) and `payg-subscription-webhook` (`customer.subscription.created`/`.deleted` drive state; `.updated` + `invoice.*` observed). Keep the legacy `stripe-webhook` only if credits/self-hosted flows still run. 4. Stripe Billing Meter: `event_name = payg_doc_units`, value key `processed_documents`. 5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET` (backend); the webhook signing secrets (edge fns). The default pricing policy must point at the PAYG Stripe Price(s); `V20` seeds `free_tier_units = 500`. ## Testing - `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests green, FE saas typecheck clean (the remaining errors are pre-existing `proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode suite + CI workflow included. ## Pre-merge review An independent dead-code/security pass came back **clean on security** (team-derived authz / no IDOR, leader-only cap mutation, no billing-category downgrade, dev/mock hooks gated to `import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open metering by design). The dead/unwired code it flagged has been removed in this branch (unenforced sub-cap control, an unused JDBC DAO + its view, dead methods). ## Follow-ups (tracked, not blocking) - **Enforce per-member sub-caps** — the control was removed because it read for display but never gated a request; the per-member usage display and `cap_units` column are retained for when enforcement is wired. - **API/AI chaining billing model + `ProcessType` enum** — confirm same-file dedup covers API chaining; define per-tool AI charging; decide whether the unused enum values stay. - **Activity feed** — hidden behind a flag until the meter-event surface is polished. --------- Co-authored-by: Reece --- .../common/service/InternalApiClient.java | 15 + .../common/service/InternalApiClientTest.java | 47 ++ .../proprietary/security/model/User.java | 6 +- .../ai/controller/AiCreateController.java | 3 + .../AiCreateInternalController.java | 3 + .../saas/ai/controller/AiProxyController.java | 3 + .../saas/config/CreditInterceptorConfig.java | 4 +- .../saas/controller/CreditController.java | 3 +- .../saas/interceptor/CreditErrorAdvice.java | 4 +- .../saas/interceptor/CreditSuccessAdvice.java | 4 +- .../interceptor/UnifiedCreditInterceptor.java | 4 +- .../software/saas/payg/api/CapMoneyUnits.java | 56 ++ .../saas/payg/api/PaygWalletController.java | 424 ++++++++++ .../saas/payg/api/WalletSnapshotResponse.java | 101 +++ .../saas/payg/billing/TeamBillingContext.java | 47 ++ .../saas/payg/billing/TeamBillingService.java | 267 ++++++ .../software/saas/payg/cap/CapEvaluator.java | 104 +++ .../saas/payg/cap/RequiresFeature.java | 46 + .../saas/payg/charge/ChargeContext.java | 15 +- .../saas/payg/charge/JobChargeService.java | 259 +++++- .../payg/entitlement/EntitlementGuard.java | 347 ++++++++ .../payg/entitlement/EntitlementService.java | 182 ++++ .../payg/entitlement/EntitlementSnapshot.java | 46 + .../payg/filter/PaygChargeInterceptor.java | 126 ++- .../saas/payg/filter/PaygWebMvcConfig.java | 22 + .../saas/payg/job/StaleJobCloser.java | 51 +- .../saas/payg/meter/PaygMeterEventLog.java | 69 ++ .../meter/PaygMeterReconcileScheduler.java | 139 +++ .../payg/meter/PaygMeterReportingService.java | 221 +++++ .../saas/payg/model/BillingCategory.java | 18 + .../saas/payg/policy/PaygTeamExtensions.java | 10 + .../saas/payg/policy/PricingPolicy.java | 18 +- .../PaygMeterEventLogRepository.java | 81 ++ .../PaygShadowChargeRepository.java | 16 + .../PaygTeamExtensionsRepository.java | 28 + .../repository/WalletLedgerRepository.java | 43 +- .../saas/payg/shadow/PaygShadowCharge.java | 29 + .../payg/stripe/StripeSubscriptionDao.java | 215 +++++ .../saas/payg/wallet/WalletLedgerEntry.java | 10 + .../repository/TeamMembershipRepository.java | 18 + .../saas/repository/UserCreditRepository.java | 10 +- .../main/resources/application-dev.properties | 7 + .../resources/application-saas.properties | 25 + .../saas/V16__payg_billing_category.sql | 58 ++ .../V17__merge_supabase_id_into_auth_id.sql | 34 + .../saas/V19__payg_lifetime_free_grant.sql | 95 +++ .../saas/V20__payg_launch_free_grant.sql | 42 + ...V21__drop_wallet_category_summary_view.sql | 11 + .../payg/api/PaygWalletControllerTest.java | 489 +++++++++++ .../saas/payg/cap/CapEvaluatorTest.java | 150 ++++ .../RequiresFeatureAnnotationRolloutTest.java | 52 ++ .../payg/charge/JobChargeServiceTest.java | 548 +++++++++++- .../entitlement/EntitlementGuardTest.java | 568 +++++++++++++ .../entitlement/EntitlementServiceTest.java | 271 ++++++ .../filter/PaygChargeInterceptorTest.java | 302 ++++++- .../saas/payg/job/StaleJobCloserTest.java | 67 +- .../PaygMeterReconcileSchedulerTest.java | 121 +++ .../meter/PaygMeterReportingServiceTest.java | 245 ++++++ .../payg/model/PaygEntitiesSmokeTest.java | 35 + .../public/locales/en-GB/translation.toml | 245 ++++++ .../core/components/shared/config/types.ts | 1 + frontend/editor/src/core/types/appConfig.ts | 1 + frontend/editor/src/saas/App.tsx | 2 + frontend/editor/src/saas/auth/UseSession.tsx | 79 +- .../components/SignupRequiredBootstrap.tsx | 129 +++ .../saas/components/shared/AppConfigModal.tsx | 35 +- .../components/shared/ManageBillingButton.tsx | 5 +- .../shared/config/configSections/Payg.css | 624 ++++++++++++++ .../shared/config/configSections/Payg.tsx | 788 ++++++++++++++++++ .../shared/config/configSections/PaygFree.css | 322 +++++++ .../shared/config/configSections/PaygFree.tsx | 374 +++++++++ .../shared/config/configSections/Plan.tsx | 294 ++----- .../config/configSections/SpendCapControl.css | 159 ++++ .../config/configSections/SpendCapControl.tsx | 245 ++++++ .../configSections/StripeCheckoutPanel.tsx | 314 +++++++ .../config/configSections/UpgradeModal.css | 440 ++++++++++ .../config/configSections/UpgradeModal.tsx | 532 ++++++++++++ .../apiKeys/hooks/useCredits.ts | 73 +- .../configSections/plan/ActivePlanSection.tsx | 143 ---- .../plan/ApiPackagesSection.tsx | 126 --- .../plan/AvailablePlansSection.tsx | 148 ---- .../config/configSections/plan/PlanCard.tsx | 113 --- .../shared/config/saasConfigNavSections.tsx | 9 +- .../editor/src/saas/hooks/useCreditCheck.ts | 77 +- .../editor/src/saas/hooks/useRenderCount.ts | 34 + frontend/editor/src/saas/hooks/useWallet.ts | 526 ++++++++++++ .../editor/src/saas/services/apiClient.ts | 36 +- .../services/paygErrorInterceptor.test.ts | 184 ++++ .../src/saas/services/paygErrorInterceptor.ts | 131 +++ 89 files changed, 11389 insertions(+), 1034 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java create mode 100644 frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/Payg.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/editor/src/saas/hooks/useRenderCount.ts create mode 100644 frontend/editor/src/saas/hooks/useWallet.ts create mode 100644 frontend/editor/src/saas/services/paygErrorInterceptor.test.ts create mode 100644 frontend/editor/src/saas/services/paygErrorInterceptor.ts diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index f72eca14d..c4663c769 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -50,6 +50,16 @@ public class InternalApiClient { "^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$" + "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$"); + /** + * Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies + * the call as {@code BillingCategory.AUTOMATION}. By construction every {@link + * InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow, + * policy runner) running a child tool inside a parent automation flow — see the saas {@code + * PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any + * per-tool {@code @RequiresFeature} annotation. + */ + public static final String AUTOMATION_HEADER = "X-Stirling-Automation"; + private final ServletContext servletContext; private final UserServiceInterface userService; private final TempFileManager tempFileManager; @@ -96,6 +106,11 @@ public class InternalApiClient { if (apiKey != null && !apiKey.isEmpty()) { headers.add("X-API-KEY", apiKey); } + // Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which + // tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR + // step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because + // every caller of this dispatcher is an automation surface by design. + headers.add(AUTOMATION_HEADER, "true"); HttpEntity> entity = new HttpEntity<>(body, headers); RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class); diff --git a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java index 06543e47b..7bab14188 100644 --- a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java +++ b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java @@ -59,6 +59,53 @@ class InternalApiClientTest { servletContext, userService, tempFileManager, environment, applicationProperties); } + @Test + void postTagsRequestAsAutomation() throws Exception { + // Every InternalApiClient.post() caller is a parent automation flow dispatching a child + // tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means + // the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of + // the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run + // bills as AUTOMATION, not AI. The header value is the literal string "true" because the + // interceptor compares case-insensitively-trimmed against that token. + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("fileInput", namedResource("input.pdf", "data")); + + Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp"); + TempFile tempFile = mock(TempFile.class); + when(tempFile.getPath()).thenReturn(tempPath); + when(tempFile.getFile()).thenReturn(tempPath.toFile()); + when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile); + + HttpHeaders[] captured = {null}; + + try (var ignored = + mockConstruction( + RestTemplate.class, + (rt, ctx) -> { + when(rt.httpEntityCallback(any(), eq(Resource.class))) + .thenAnswer( + inv -> { + HttpEntity entity = inv.getArgument(0); + captured[0] = entity.getHeaders(); + return (RequestCallback) req -> {}; + }); + when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any())) + .thenAnswer(inv -> fakeOkResponse(inv.getArgument(3))); + })) { + + InternalApiClient mockedClient = newClient(); + mockedClient.post("/api/v1/general/merge-pdfs", body); + + assertNotNull(captured[0]); + assertEquals( + "true", + captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER), + "Sub-step dispatch must carry the automation marker header"); + } finally { + Files.deleteIfExists(tempPath); + } + } + @Test void postDoesNotForceContentType() throws Exception { MultiValueMap body = new LinkedMultiValueMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index e39b1a330..2b2d22cfc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -87,7 +87,11 @@ public class User implements UserDetails, Serializable { private String email; // SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments. - @Column(name = "supabase_id", unique = true) + // Column is `supabase_auth_id` (canonical name from the initial Supabase remote + // schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a + // parallel `supabase_id` column that was used by Java; V17 backfilled and dropped + // it. Field name is kept as `supabaseId` to avoid a wide refactor of callers. + @Column(name = "supabase_auth_id", unique = true) private UUID supabaseId; @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 848d596d8..9f279a6b8 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -43,6 +43,8 @@ import stirling.software.saas.ai.model.AiCreateSession; import stirling.software.saas.ai.repository.AiCreateSessionRepository; import stirling.software.saas.ai.service.AiCreateProxyService; import stirling.software.saas.ai.service.AiCreateSessionService; +import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.service.CreditService; import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; @@ -54,6 +56,7 @@ import stirling.software.saas.util.CreditHeaderUtils; @Tag(name = "AI") @Hidden @RequiredArgsConstructor +@RequiresFeature(FeatureGate.AI_SUPPORT) @Slf4j public class AiCreateController { diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index bf2e4894e..60c8dc461 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -26,6 +26,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.saas.ai.model.AiCreateSession; import stirling.software.saas.ai.model.AiCreateSessionStatus; import stirling.software.saas.ai.service.AiCreateSessionService; +import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.model.FeatureGate; @RestController @Profile("saas") @@ -33,6 +35,7 @@ import stirling.software.saas.ai.service.AiCreateSessionService; @Tag(name = "AI") @Hidden @RequiredArgsConstructor +@RequiresFeature(FeatureGate.AI_SUPPORT) @Slf4j public class AiCreateInternalController { diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java index 8b6ae714a..60549208d 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java @@ -28,6 +28,8 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.saas.ai.service.AiProxyService; +import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.service.CreditService; import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; @@ -36,6 +38,7 @@ import stirling.software.saas.util.CreditHeaderUtils; @RestController @Profile("saas") @RequestMapping("/api/v1/ai") +@RequiresFeature(FeatureGate.AI_SUPPORT) @Tag(name = "AI") @Hidden @Slf4j diff --git a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java index 978e9b34f..2cce36a5a 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java @@ -9,8 +9,10 @@ import lombok.RequiredArgsConstructor; import stirling.software.saas.interceptor.UnifiedCreditInterceptor; +// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits +// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back. @Configuration -@Profile("saas") +@Profile("saas & legacy-credits") @RequiredArgsConstructor public class CreditInterceptorConfig implements WebMvcConfigurer { diff --git a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java index 2c1be26f4..6f33554fc 100644 --- a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java +++ b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java @@ -25,8 +25,9 @@ import stirling.software.saas.service.CreditService; import stirling.software.saas.service.CreditService.CreditSummary; import stirling.software.saas.util.LogRedactionUtils; +// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile. @RestController -@Profile("saas") +@Profile("saas & legacy-credits") @RequestMapping("/api/v1/credits") @Tag(name = "Credit Management", description = "Endpoints for managing user API credits") @RequiredArgsConstructor diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java index 280f8be6d..24c73ac78 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java @@ -39,8 +39,10 @@ import stirling.software.saas.util.CreditHeaderUtils; * Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global * exception flow. */ +// Legacy credit-billing error advice. PAYG handles its own error semantics via +// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed. @RestControllerAdvice(annotations = AutoJobPostMapping.class) -@Profile("saas") +@Profile("saas & legacy-credits") @Slf4j @Order(1) public class CreditErrorAdvice { diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java index 51e82b4db..e76e06e3c 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java @@ -27,8 +27,10 @@ import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; import stirling.software.saas.util.CreditHeaderUtils; +// Legacy credit-billing success advice. PAYG writes its own ledger entries via +// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed. @RestControllerAdvice -@Profile("saas") +@Profile("saas & legacy-credits") @Slf4j public class CreditSuccessAdvice implements ResponseBodyAdvice { diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java index 631217e1c..f50c1df0f 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java @@ -32,8 +32,10 @@ import stirling.software.saas.service.SaasUserExtensionService; import stirling.software.saas.service.TeamCreditService; import stirling.software.saas.util.AuthenticationUtils; +// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled +// by default in saas, activate legacy-credits profile to bring it back. @Component -@Profile("saas") +@Profile("saas & legacy-credits") @Slf4j public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java b/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java new file mode 100644 index 000000000..7ecad6a8b --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java @@ -0,0 +1,56 @@ +package stirling.software.saas.payg.api; + +/** + * Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on + * {@code wallet_policy.cap_units}. + * + *

The cap is application-layer only — Stripe stays on a flat-priced single meter, so the + * conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader + * who set "$25" should read back "$25" on the next page load. + * + *

V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the + * same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against + * the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money + * conversion lands. + * + *

Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar + * inputs (the cap-edit field is an integer text box) so the floor on the read path is the only + * place rounding ever shows up, and only when an admin set a non-multiple via SQL. + */ +public final class CapMoneyUnits { + + /** + * Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the + * unit-count meter the engine writes to the ledger; not tied to Stripe pricing. + */ + public static final int UNITS_PER_USD = 100; + + /** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */ + public static final int CENTS_PER_USD = 100; + + private CapMoneyUnits() {} + + /** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */ + public static long usdToUnits(int capUsd) { + if (capUsd < 0) { + throw new IllegalArgumentException("capUsd must be >= 0"); + } + return (long) capUsd * UNITS_PER_USD; + } + + /** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */ + public static int unitsToUsd(long capUnits) { + if (capUnits < 0L) { + return 0; + } + return (int) (capUnits / UNITS_PER_USD); + } + + /** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */ + public static long usdToCents(int capUsd) { + if (capUsd < 0) { + throw new IllegalArgumentException("capUsd must be >= 0"); + } + return (long) capUsd * CENTS_PER_USD; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java new file mode 100644 index 000000000..ee2de60c1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -0,0 +1,424 @@ +package stirling.software.saas.payg.api; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow; +import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; +import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygShadowChargeRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.WalletLedgerRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; +import stirling.software.saas.payg.wallet.WalletPolicy; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Read + cap-mutation surface backing the FE PAYG Plan page. + * + *

{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns + * a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for + * spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link + * WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster + * of team members + their per-member usage; member callers see an empty roster. + * + *

{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the + * cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot + * cache so the next read reflects the change immediately. Only leaders may call this; the team is + * derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize} + * — the team id never appears on the path or query string. + * + *

Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in + * V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as + * {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription + * id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose + * subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}). + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/payg") +@Profile("saas") +public class PaygWalletController { + + static final String STATUS_FREE = "free"; + static final String STATUS_SUBSCRIBED = "subscribed"; + static final String ROLE_LEADER = "leader"; + static final String ROLE_MEMBER = "member"; + + /** + * Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a + * membership — shouldn't happen post-migration). Teams always get the live {@code + * pricing_policy.free_tier_units} grant via {@link TeamBillingService}. + */ + private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500; + + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE; + + private final EntitlementService entitlementService; + private final TeamBillingService billingService; + private final TeamMembershipRepository memberRepo; + private final PaygTeamExtensionsRepository extRepo; + private final WalletPolicyRepository policyRepo; + private final WalletLedgerRepository ledgerRepo; + private final PaygShadowChargeRepository shadowRepo; + private final UserRepository userRepository; + + public PaygWalletController( + EntitlementService entitlementService, + TeamBillingService billingService, + TeamMembershipRepository memberRepo, + PaygTeamExtensionsRepository extRepo, + WalletPolicyRepository policyRepo, + WalletLedgerRepository ledgerRepo, + PaygShadowChargeRepository shadowRepo, + UserRepository userRepository) { + this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService"); + this.billingService = Objects.requireNonNull(billingService, "billingService"); + this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo"); + this.extRepo = Objects.requireNonNull(extRepo, "extRepo"); + this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo"); + this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo"); + this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo"); + this.userRepository = Objects.requireNonNull(userRepository, "userRepository"); + } + + // --------------------------------------------------------------------------------------- + // GET /wallet — the single FE fetch + // --------------------------------------------------------------------------------------- + + @GetMapping("/wallet") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity getWallet(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + // SecurityException maps to 401 per the existing controller convention. + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + Optional primary = primaryMembership(user.getId()); + if (primary.isEmpty()) { + // Authenticated user without a team — shouldn't happen post-migration, but we don't + // want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI + // rather than blowing up on a null body. + return ResponseEntity.ok(emptySnapshot()); + } + + TeamMembership membership = primary.get(); + Long teamId = membership.getTeam().getId(); + boolean isLeader = membership.getRole() == TeamRole.LEADER; + + // Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement + // snapshot (period spend over that window) share the same composition service, so what + // the customer sees here is exactly what the 402 guard enforces. + TeamBillingContext billing = billingService.forTeam(teamId); + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + + String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE; + + boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null; + Integer capMajor = + billing.capMoneyMinor() != null + ? Math.toIntExact(billing.capMoneyMinor() / 100) + : null; + + // Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is + // lifetime free used, cap is the grant size; subscribed → spend is this month's net + // billable + // docs, cap is the monthly paid-doc ceiling (null = uncapped). + int spend = clampToInt(snap.periodSpendUnits()); + Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null; + + CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd()); + + // Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was + // already netted out at charge time, so this is the metered total, not spend − grant. + long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd()); + Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null); + + List members = + isLeader + ? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd()) + : List.of(); + + WalletSnapshotResponse body = + new WalletSnapshotResponse( + teamId, + status, + isLeader ? ROLE_LEADER : ROLE_MEMBER, + ISO_DATE.format(snap.periodStart().toLocalDate()), + ISO_DATE.format(snap.periodEnd().toLocalDate()), + spend, + limit, + clampToInt(billing.freeGrantUnits()), + clampToInt(billing.freeRemainingUnits()), + billing.perDocMinor(), + billing.currency(), + estimatedBill, + capMajor, + noCap, + billing.subscriptionId(), + spend, + breakdown, + members, + buildActivity(teamId)); + return ResponseEntity.ok(body); + } + + private CategoryBreakdown buildBreakdown( + Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { + Map byCategory = new HashMap<>(); + for (Object[] row : + ledgerRepo.sumPeriodAmountByCategory( + teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) { + if (row.length >= 2 + && row[0] instanceof BillingCategory cat + && row[1] instanceof Number n) { + byCategory.put(cat, n.longValue()); + } + } + return new CategoryBreakdown( + clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)), + clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)), + clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L))); + } + + /** + * Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as + * credits-back; system entries without a category render as {@code other}. + */ + private List buildActivity(Long teamId) { + List out = new ArrayList<>(); + for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) { + BillingCategory category = e.getBillingCategory(); + String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other"; + String categoryLabel = category != null ? categoryDisplayName(category) : "Document"; + String label = + e.getEntryType() == LedgerEntryType.REFUND + ? "Refund — " + categoryLabel + : categoryLabel + " usage"; + int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits()); + out.add( + new ActivityRow( + e.getId(), + kind, + label, + e.getOccurredAt() != null ? e.getOccurredAt().toString() : "", + docUnits)); + } + return out; + } + + private static String categoryDisplayName(BillingCategory category) { + return switch (category) { + case API -> "API"; + case AI -> "AI"; + case AUTOMATION -> "Automation"; + case BYPASSED -> "Manual"; + }; + } + + // --------------------------------------------------------------------------------------- + // PATCH /cap — leader-only, cap is application-layer, no Stripe call + // --------------------------------------------------------------------------------------- + + @PatchMapping("/cap") + @PreAuthorize("isAuthenticated()") + @Transactional + public ResponseEntity updateCap( + @Valid @RequestBody UpdateCapRequest req, Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Optional primary = primaryMembership(user.getId()); + if (primary.isEmpty()) { + // No team → can't have a wallet to cap. + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + TeamMembership membership = primary.get(); + if (membership.getRole() != TeamRole.LEADER) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + Long teamId = membership.getTeam().getId(); + + WalletPolicy policy = + policyRepo + .findByTeamId(teamId) + .orElseGet( + () -> { + WalletPolicy created = new WalletPolicy(); + created.setTeamId(teamId); + return created; + }); + + if (req.noCap()) { + policy.setCapUnits(null); + policy.setCapSourceMoney(null); + } else { + long capMinor = CapMoneyUnits.usdToCents(req.capUsd()); + policy.setCapSourceMoney(capMinor); + // Derived document allowance (design §10: store both the money intent and the unit + // translation). The live snapshot recomputes from cap_source_money + current rate; + // this stored value is the enforcement fallback when the rate is unreachable. + TeamBillingContext billing = billingService.forTeam(teamId); + Optional docCap = billingService.docCapForMoney(billing, capMinor); + if (docCap.isPresent()) { + policy.setCapUnits(docCap.get()); + } else { + // Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy + // money-as-units conversion so the cap still binds rather than silently lifting. + log.warn( + "Per-document rate unavailable for team {}; storing legacy cap_units" + + " conversion.", + teamId); + policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd())); + } + } + policyRepo.save(policy); + entitlementService.invalidate(teamId); + return ResponseEntity.noContent().build(); + } + + /** Request body for {@link #updateCap}. */ + public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + + // --------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------- + + private Optional primaryMembership(Long userId) { + List rows = memberRepo.findPrimaryMembership(userId); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + private List buildMemberRows( + Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { + List all = memberRepo.findByTeamId(teamId); + if (all.isEmpty()) { + return List.of(); + } + LocalDateTime[] window = {periodStart, periodEnd}; + List out = new ArrayList<>(all.size()); + for (TeamMembership tm : all) { + User u = tm.getUser(); + if (u == null) { + continue; + } + // We could batch these; team sizes are small (FE design assumes ≤ ~20 members per + // team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to + // a single GROUP BY actor_user_id query. + long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate. + try { + spend = -memberSpend(teamId, u.getId(), window[0], window[1]); + } catch (RuntimeException e) { + log.warn( + "buildMemberRows: per-member spend lookup failed for user {}", + u.getId(), + e); + } + String displayName = + Optional.ofNullable(u.getUsername()) + .orElse(Optional.ofNullable(u.getEmail()).orElse("")); + out.add( + new MemberRow( + Long.toString(u.getId()), + displayName, + Optional.ofNullable(u.getEmail()).orElse(""), + clampToInt(spend))); + } + return out; + } + + /** + * Per-member period spend in signed ledger units (debits are negative). Helper so the test + * slice can override without standing up a real database, and so the controller doesn't inline + * the negation arithmetic at every call site. + */ + long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) { + return ledgerRepo.sumPeriodAmountForMember( + teamId, userId, LedgerEntryType.DEBIT, start, end); + } + + private static LocalDateTime[] currentMonthWindow() { + java.time.YearMonth ym = java.time.YearMonth.now(); + LocalDateTime start = ym.atDay(1).atStartOfDay(); + LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(); + return new LocalDateTime[] {start, end}; + } + + private static int clampToInt(long v) { + if (v <= 0) return 0; + if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE; + return (int) v; + } + + private WalletSnapshotResponse emptySnapshot() { + LocalDateTime[] window = currentMonthWindow(); + return new WalletSnapshotResponse( + null, // teamId — unknown when the caller has no team membership + STATUS_FREE, + ROLE_MEMBER, + ISO_DATE.format(window[0].toLocalDate()), + ISO_DATE.format(window[1].toLocalDate()), + 0, + FREE_TIER_LIMIT_UNITS_FALLBACK, + FREE_TIER_LIMIT_UNITS_FALLBACK, + FREE_TIER_LIMIT_UNITS_FALLBACK, + null, + null, + null, + null, + false, + null, + 0, + new CategoryBreakdown(0, 0, 0), + List.of(), + Collections.emptyList()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java new file mode 100644 index 000000000..3a70cecb8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -0,0 +1,101 @@ +package stirling.software.saas.payg.api; + +import java.math.BigDecimal; +import java.util.List; + +/** + * JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the + * frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members}, + * breakdowns, recent activity) used by the PAYG Plan page. + * + *

Every number is real: the billing window is the Stripe subscription's current period (via Sync + * Engine) for subscribed teams, the one-time free grant size comes from {@code + * pricing_policy.free_tier_units} (live balance from {@code + * payg_team_extensions.free_units_remaining}), and the per-document rate comes from the + * subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders + * "unknown" — never a substituted default. + * + * @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the + * Supabase edge functions that create Stripe Checkout / portal sessions — those run outside + * Spring Security and have no other way to resolve the caller's team. + * @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once + * a card is on file and the engine bills meter events. + * @param role the current caller's role within their team — {@code "leader"} or {@code "member"}. + * Controls which UI variant the frontend renders. + * @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe + * subscription period when subscribed, the calendar month otherwise. + * @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle. + * @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free + * team this is the lifetime free documents used so far ({@code freeAllowance − freeRemaining}); + * for a subscribed team it's this month's net billable documents. + * @param billableLimit the team's document ceiling for the matching window: the one-time free grant + * ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for + * capped subscribed teams; {@code null} when subscribed with no cap (uncapped). + * @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free"). + * Never resets; survives subscribing. Applies to billable categories only. + * @param freeRemaining one-time free documents still available to the team ({@code + * payg_team_extensions.free_units_remaining}). 0 = grant exhausted. + * @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be + * fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved. + * @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null} + * when unknown (free teams, unresolved rate). + * @param estimatedBillMinor estimated charges so far this period in minor units of {@code + * currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free + * portion was already netted out at charge time. Informational — the Stripe invoice is + * authoritative. {@code null} when the rate is unknown. + * @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free + * or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs + * it with {@code currency} for the symbol.) + * @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when + * subscribed. + * @param stripeSubscriptionId Stripe subscription id from {@code + * payg_team_extensions.payg_subscription_id}; {@code null} when status is free. + * @param spendUnitsThisPeriod documents debited this cycle across billable categories. + * @param categoryBreakdown per-category spend slice over the same billing window. + * @param members leader-only roster of team members + their per-member sub-caps. Empty for member + * callers. + * @param recent latest wallet-ledger entries (newest first) for the activity feed. + */ +public record WalletSnapshotResponse( + Long teamId, + String status, + String role, + String billingPeriodStart, + String billingPeriodEnd, + int billableUsed, + Integer billableLimit, + int freeAllowance, + int freeRemaining, + BigDecimal pricePerDocMinor, + String currency, + Long estimatedBillMinor, + Integer capUsd, + boolean noCap, + String stripeSubscriptionId, + int spendUnitsThisPeriod, + CategoryBreakdown categoryBreakdown, + List members, + List recent) { + + /** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */ + public record CategoryBreakdown(int api, int ai, int automation) {} + + /** + * One row of the team-members table on the leader's Plan page — display-only per-member usage. + * (Per-member sub-caps aren't enforced yet; see the follow-ups note. When they ship, a cap + * field returns here.) + */ + public record MemberRow(String userId, String name, String email, int spendUnits) {} + + /** + * One wallet-ledger entry shaped for the FE activity feed. + * + * @param id ledger entry id (stable React key) + * @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or + * {@code other} for system entries + * @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"} + * @param ts ISO-8601 local timestamp of the entry + * @param docUnits absolute document count of the entry + */ + public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java new file mode 100644 index 000000000..52cb15bb2 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java @@ -0,0 +1,47 @@ +package stirling.software.saas.payg.billing; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live + * here and must not be conflated: + * + *

    + *
  • the one-time lifetime free grant ({@link #freeGrantUnits} total, {@link + * #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split + * of every job; never resets, survives subscribing; + *
  • the monthly billing window ({@link #periodStart}/{@link #periodEnd}) and the + * optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice + * + cap only. + *
+ * + * @param subscribed team has an active PAYG subscription (or a Stripe customer awaiting its + * subscription-created webhook) + * @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free + * @param periodStart inclusive start of the monthly billing window — the Stripe subscription's + * current period when subscribed, calendar month otherwise + * @param periodEnd exclusive end of the monthly billing window + * @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the + * denominator for "used X of N free". Never resets. + * @param freeRemainingUnits one-time free documents still available ({@code + * payg_team_extensions.free_units_remaining}). 0 = grant exhausted. + * @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the + * rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute + * @param currency lower-case ISO 4217 of the subscription's Price; null when unknown + * @param capMoneyMinor leader-set monthly spending cap in minor units ({@code + * wallet_policy.cap_source_money}); null = no cap configured + * @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney / + * perDocRate)}; null = uncapped, or the team is not subscribed + */ +public record TeamBillingContext( + boolean subscribed, + String subscriptionId, + LocalDateTime periodStart, + LocalDateTime periodEnd, + long freeGrantUnits, + long freeRemainingUnits, + BigDecimal perDocMinor, + String currency, + Long capMoneyMinor, + Long monthlyCapDocUnits) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java new file mode 100644 index 000000000..9a697392d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -0,0 +1,267 @@ +package stirling.software.saas.payg.billing; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Single composition point for "what does billing look like for this team right now." Both the + * entitlement hot path and the wallet endpoint read from here, so what the customer sees is what + * the guard enforces. + * + *

Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant): + * + *

    + *
  • Free grant — one-time, per team. Size from {@code pricing_policy.free_tier_units}; + * live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained + * by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and + * drives the free-vs-paid split. + *
  • Monthly window + cap — the Stripe subscription period (calendar month otherwise) and + * the optional money cap. Govern the subscribed invoice + spending cap only. The per-document + * rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit). + *
+ * + *

Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate} + * cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks. + * Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the + * authoritative decrement happens in {@code JobChargeService} against the row directly; this cache + * is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor. + */ +@Slf4j +@Service +@Profile("saas") +public class TeamBillingService { + + static final int CACHE_TTL_SECONDS = 30; + private static final int CACHE_MAX_SIZE = 10_000; + + /** + * In-app display/estimate currency. The app prices in dollars; Stripe handles real currency + * selection at checkout. Used to pick the right Price for un-subscribed teams. + */ + private static final String DISPLAY_CURRENCY = "usd"; + + /** + * Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve + * an un-subscribed team's rate from (the default policy carries no price ids in the seed). + */ + private static final String PAYG_LOOKUP_KEY = "plan:processor"; + + private final PaygTeamExtensionsRepository extensionsRepository; + private final WalletPolicyRepository walletPolicyRepository; + private final PricingPolicyService pricingPolicyService; + private final StripeSubscriptionDao subscriptionDao; + + private final Cache cache; + + public TeamBillingService( + PaygTeamExtensionsRepository extensionsRepository, + WalletPolicyRepository walletPolicyRepository, + PricingPolicyService pricingPolicyService, + StripeSubscriptionDao subscriptionDao) { + this.extensionsRepository = + Objects.requireNonNull(extensionsRepository, "extensionsRepository"); + this.walletPolicyRepository = + Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository"); + this.pricingPolicyService = + Objects.requireNonNull(pricingPolicyService, "pricingPolicyService"); + this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao"); + this.cache = + Caffeine.newBuilder() + .maximumSize(CACHE_MAX_SIZE) + .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS)) + .build(); + } + + public TeamBillingContext forTeam(Long teamId) { + Objects.requireNonNull(teamId, "teamId"); + return cache.get(teamId, this::compute); + } + + /** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */ + public void invalidate(Long teamId) { + if (teamId != null) { + cache.invalidate(teamId); + } + } + + private TeamBillingContext compute(Long teamId) { + Optional extOpt = extensionsRepository.findById(teamId); + Optional walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId); + + String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null); + // payg_subscription_id is the designed switch; stripe_customer_id presence is the + // pre-webhook stand-in kept so a team whose checkout completed but whose + // subscription-created webhook hasn't landed yet still renders as subscribed. + boolean subscribed = + subscriptionId != null + || extOpt.map(PaygTeamExtensions::getStripeCustomerId) + .filter(s -> !s.isBlank()) + .isPresent(); + + long freeGrant = resolveGrant(teamId); + long freeRemaining = + extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining) + .map(Long::longValue) + .orElse(0L); + + Optional billing = + subscriptionId != null + ? subscriptionDao.findBilling(subscriptionId) + : Optional.empty(); + + LocalDateTime[] window = + billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()}) + .orElseGet(TeamBillingService::calendarMonthWindow); + + BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null); + String currency = billing.map(SubscriptionBilling::currency).orElse(null); + + // Un-subscribed teams have no Stripe subscription to read a rate from, but the cap + // estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from + // the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and + // the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on + // `subscribed`, so this never starts enforcing a cap on a free team. + if (!subscribed && perDocMinor == null) { + Optional rate = + subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY); + if (rate.isPresent()) { + perDocMinor = rate.get().perDocMinor(); + currency = rate.get().currency(); + } + } + + Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null); + Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null); + + Long monthlyCapDocUnits = + resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor); + + return new TeamBillingContext( + subscribed, + subscriptionId, + window[0], + window[1], + freeGrant, + freeRemaining, + perDocMinor, + currency, + capMoneyMinor, + monthlyCapDocUnits); + } + + /** The policy grant size — the "N" denominator for display; the counter is the live balance. */ + private long resolveGrant(Long teamId) { + try { + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); + Long grant = policy.getFreeTierUnits(); + return grant == null ? 0L : grant; + } catch (RuntimeException e) { + log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage()); + return 0L; + } + } + + /** + * The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The + * one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge + * time. The cap purely limits how many paid documents the team will fund per billing period. + * + *

    + *
  • not subscribed → null (the free grant, not a money cap, is what bounds them); + *
  • subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units}; + *
  • subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)}; + *
  • subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN). + *
+ */ + private Long resolveMonthlyCap( + boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) { + if (!subscribed) { + return null; + } + if (capMoneyMinor == null) { + return legacyCapUnits; // admin-set unit cap (source money null) still applies + } + if (perDocMinor != null && perDocMinor.signum() > 0) { + return BigDecimal.valueOf(capMoneyMinor) + .divide(perDocMinor, 0, RoundingMode.FLOOR) + .longValue(); + } + log.warn( + "Per-document rate unavailable; enforcing stored cap_units fallback ({}).", + legacyCapUnits); + return legacyCapUnits; + } + + /** + * Estimated charges for the current period in minor units of {@link + * TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document + * rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown. + * + * @param paidUnitsThisPeriod metered documents this period ({@code payg_units − + * free_units_consumed} summed over the period's charged jobs) + */ + public Optional estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) { + if (ctx.perDocMinor() == null) { + return Optional.empty(); + } + long paid = Math.max(0, paidUnitsThisPeriod); + BigDecimal bill = + ctx.perDocMinor() + .multiply(BigDecimal.valueOf(paid)) + .setScale(0, RoundingMode.HALF_UP); + return Optional.of(bill.longValue()); + } + + /** + * Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by + * the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT + * added — it's a separate one-time pool. Empty when the rate is unknown. + */ + public Optional docCapForMoney(TeamBillingContext ctx, long capMinor) { + if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) { + return Optional.empty(); + } + return Optional.of( + BigDecimal.valueOf(capMinor) + .divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR) + .longValue()); + } + + /** + * Inclusive-start / exclusive-end window for the calendar month — the monthly billing window + * used when there's no Stripe subscription period to anchor on. + */ + static LocalDateTime[] calendarMonthWindow() { + return calendarMonthWindow(LocalDateTime.now()); + } + + /** Test seam — accepts a clock value so tests don't race the calendar boundary. */ + static LocalDateTime[] calendarMonthWindow(LocalDateTime now) { + java.time.YearMonth ym = java.time.YearMonth.from(now); + return new LocalDateTime[] { + ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay() + }; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java new file mode 100644 index 000000000..43819f3a3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java @@ -0,0 +1,104 @@ +package stirling.software.saas.payg.cap; + +import java.util.List; + +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade + * thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in + * effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller + * (entitlement service) supplies the inputs. + * + *

State transitions (matching {@code notes/PAYG_DESIGN.md} §3.6): + * + *

    + *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. + *
  • {@code spend / cap < warnPct} → {@code FULL}. + *
  • MINIMAL semantics: under DEGRADED+MINIMAL manual server-side tools (gated by {@link + * FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link + * FeatureGate#AUTOMATION} and {@link FeatureGate#AI_SUPPORT} are blocked. + *
  • {@code warnPct ≤ spend / cap < degradePct} → {@code WARNED}; feature set still {@link + * FeatureSet#FULL} — the warn band is a notification trigger, not a degradation. + *
  • {@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's + * configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}). + *
+ * + *

The percentage compare is integer math. We multiply spend by 100 before dividing — this keeps + * the precision and avoids floating-point on the hot path. Spend × 100 can overflow long at 9.2e16 + * units, which is not a realistic value (would represent quintillions of charged documents); we + * don't guard against it. + */ +public final class CapEvaluator { + + private CapEvaluator() {} + + /** + * Snapshot of one cap evaluation. The caller persists this into the appropriate {@code + * wallet_entitlement_snapshot} row (team-wide or per-member). + */ + public record Evaluation( + EntitlementState state, FeatureSet featureSet, List enabledGates) {} + + public static Evaluation evaluate( + long spendUnits, + Long capUnits, + int warnAtPct, + int degradeAtPct, + FeatureSet degradedFeatureSet) { + + if (capUnits == null || capUnits <= 0) { + return full(); + } + if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) { + // Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise + // degradation. The admin endpoints that set the policy should validate; this + // protects the hot path from a bad row sneaking through. + return full(); + } + + // pct = floor((spend * 100) / cap). Integer arithmetic on the hot path. + long pct = (spendUnits * 100L) / capUnits; + + if (pct >= degradeAtPct) { + FeatureSet effective = + degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL; + return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective)); + } + if (pct >= warnAtPct) { + // Warn band: still FULL feature set, but state flag is set so the FE can show a + // banner / send a notification. The wallet service emits a + // WalletEntitlementChanged event when state transitions; subscribers (email + // reminder, SSE to FE) act on that. + return new Evaluation( + EntitlementState.WARNED, FeatureSet.FULL, gatesFor(FeatureSet.FULL)); + } + return full(); + } + + /** + * Default enabled gates for a given feature set. Kept in sync with the design doc §3.7 mapping + * table. + */ + public static List gatesFor(FeatureSet set) { + if (set == null) { + return List.of(); + } + return switch (set) { + case FULL -> + List.of( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE); + case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE); + }; + } + + private static Evaluation full() { + return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java new file mode 100644 index 000000000..97a61d0f3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java @@ -0,0 +1,46 @@ +package stirling.software.saas.payg.cap; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import stirling.software.saas.payg.model.FeatureGate; + +/** + * Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by + * {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the + * request is rejected with HTTP 402. + * + *

The annotation is not required on every endpoint. The guard's default rule is: + * + *

    + *
  • {@code @RequiresFeature} present → use exactly those gates. + *
  • No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link + * FeatureGate#OFFSITE_PROCESSING}. + *
  • Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't + * degrade). + *
+ * + *

So the only endpoints that need this annotation explicitly are those whose gate is + * different from the default {@code OFFSITE_PROCESSING} — chiefly {@code + * PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link + * FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal. + * + *

Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate; + * the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs + * both {@code AUTOMATION} and {@code AI_SUPPORT}). + * + *

{@code
+ * @RequiresFeature(FeatureGate.AUTOMATION)
+ * @AutoJobPostMapping("/pipeline")
+ * public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
+ * }
+ */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresFeature { + + /** One or more gates that must all be enabled for the request to proceed. */ + FeatureGate[] value(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java index ff4c94a13..f5914f853 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java @@ -1,5 +1,6 @@ package stirling.software.saas.payg.charge; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ProcessType; @@ -8,9 +9,18 @@ import stirling.software.saas.payg.model.ProcessType; * kind of process this is. Does NOT carry policy fields — the charge service resolves the effective * policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from * the live policy. + * + *

{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by + * the interceptor before this context is built. Manual UI tools never reach {@code openProcess} + * (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore + * carries one of {@code API}, {@code AI}, or {@code AUTOMATION}. */ public record ChargeContext( - Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) { + Long ownerUserId, + Long ownerTeamId, + JobSource source, + ProcessType processType, + BillingCategory billingCategory) { public ChargeContext { if (ownerUserId == null) { @@ -22,5 +32,8 @@ public record ChargeContext( if (processType == null) { throw new IllegalArgumentException("processType is required"); } + if (billingCategory == null) { + throw new IllegalArgumentException("billingCategory is required"); + } } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index e5832bfd6..37875060e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -11,6 +11,8 @@ import java.util.UUID; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import lombok.extern.slf4j.Slf4j; @@ -21,14 +23,23 @@ import stirling.software.saas.payg.job.JobContext; import stirling.software.saas.payg.job.JobService; import stirling.software.saas.payg.job.JoinOrOpenResult; import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.meter.PaygMeterReportingService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.JobStatus; +import stirling.software.saas.payg.model.LedgerBucket; +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.model.ReferenceType; import stirling.software.saas.payg.model.ShadowChargeStatus; +import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; import stirling.software.saas.payg.repository.PaygShadowChargeRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.ProcessingJobRepository; +import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.shadow.PaygShadowCharge; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; /** * Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve @@ -55,18 +66,29 @@ public class JobChargeService { private final DocumentClassifier classifier; private final PaygShadowChargeRepository shadowRepository; private final ProcessingJobRepository jobRepository; + private final PaygTeamExtensionsRepository teamExtensionsRepository; + private final PaygMeterReportingService meterReportingService; + private final WalletLedgerRepository ledgerRepository; public JobChargeService( JobService jobService, PricingPolicyService policyService, DocumentClassifier classifier, PaygShadowChargeRepository shadowRepository, - ProcessingJobRepository jobRepository) { + ProcessingJobRepository jobRepository, + PaygTeamExtensionsRepository teamExtensionsRepository, + PaygMeterReportingService meterReportingService, + WalletLedgerRepository ledgerRepository) { this.jobService = Objects.requireNonNull(jobService, "jobService"); this.policyService = Objects.requireNonNull(policyService, "policyService"); this.classifier = Objects.requireNonNull(classifier, "classifier"); this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository"); this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository"); + this.teamExtensionsRepository = + Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository"); + this.meterReportingService = + Objects.requireNonNull(meterReportingService, "meterReportingService"); + this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); } /** @@ -104,11 +126,70 @@ public class JobChargeService { int units = computeUnits(inputs, policy); result.job().setDocUnits(units); - recordShadowRow(ctx, result.job().getId(), policy.getId(), units); + int freeUsed = consumeFreeGrant(ctx, units); + recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed); + recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units); return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED); } + /** + * Draw this job's free portion from the team's one-time lifetime grant, atomically, and return + * the units taken (0..{@code units}); the remainder is the paid portion that will be metered to + * Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so + * concurrent same-team charges split the grant exactly — no two jobs can both claim the last + * free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses + * the boundary takes whatever's left (its remaining units bill). Skipped for non-billable / + * team-less calls (BYPASSED never reaches openProcess; guarded defensively). + */ + private int consumeFreeGrant(ChargeContext ctx, int units) { + BillingCategory category = ctx.billingCategory(); + if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) { + return 0; + } + Optional extOpt = + teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId()); + if (extOpt.isEmpty()) { + return 0; + } + PaygTeamExtensions ext = extOpt.get(); + long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining(); + int freeUsed = (int) Math.min(units, Math.max(0L, remaining)); + if (freeUsed > 0) { + ext.setFreeUnitsRemaining(remaining - freeUsed); + teamExtensionsRepository.save(ext); + } + return freeUsed; + } + + /** + * The live spend record. Everything the customer-facing side reads — the wallet endpoint's + * {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary} + * view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow + * rows are the comparison audit trail; this row is what actually counts. + * + *

Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum). + * Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed. + */ + private void recordLedgerDebit( + ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { + BillingCategory category = ctx.billingCategory(); + if (category == null || category == BillingCategory.BYPASSED) { + return; + } + WalletLedgerEntry entry = new WalletLedgerEntry(); + entry.setTeamId(ctx.ownerTeamId()); + entry.setActorUserId(ctx.ownerUserId()); + entry.setEntryType(LedgerEntryType.DEBIT); + entry.setBucket(LedgerBucket.CYCLE); + entry.setAmountUnits(-units); + entry.setReferenceType(ReferenceType.JOB); + entry.setReferenceId(jobId.toString()); + entry.setPolicyId(policyId); + entry.setBillingCategory(category); + ledgerRepository.save(entry); + } + private int resolveStepLimit(PricingPolicy policy, JobSource source) { Integer fromPolicy = policy.getStepLimits() == null ? null : policy.getStepLimits().get(source); @@ -142,17 +223,28 @@ public class JobChargeService { } private void recordShadowRow( - ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { + ChargeContext ctx, + java.util.UUID jobId, + Long policyId, + int units, + int freeUnitsConsumed) { PaygShadowCharge row = new PaygShadowCharge(); row.setTeamId(ctx.ownerTeamId()); row.setJobId(jobId); row.setPolicyId(policyId); row.setPaygUnits(units); + // Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed, + // and a refund restores freeUnitsConsumed to the team's grant. + row.setFreeUnitsConsumed(freeUnitsConsumed); // No legacy comparison yet — wired when the shadow path is connected to the legacy // CreditService in the follow-up PR. Until then, diff stays at 0. row.setLegacyCreditsCharged(0); row.setDiffPct(0); row.setStatus(ShadowChargeStatus.CHARGED); + // PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing + // after processing_job is pruned. Never affects what Stripe meters (single flat meter). + row.setBillingCategory(ctx.billingCategory()); + row.setJobSource(ctx.source()); shadowRepository.save(row); } @@ -182,6 +274,31 @@ public class JobChargeService { row.setRefundedAt(now); row.setRefundReason(trimReason(refundReason)); shadowRepository.save(row); + // Compensate the live ledger DEBIT written at openProcess so the period spend + // nets to zero for the failed work. Positive amount mirrors the negative debit; + // same JOB reference ties the pair together. The idempotency guard above (only + // on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation. + BillingCategory category = row.getBillingCategory(); + if (category != null && category != BillingCategory.BYPASSED) { + WalletLedgerEntry refund = new WalletLedgerEntry(); + refund.setTeamId(row.getTeamId()); + refund.setEntryType(LedgerEntryType.REFUND); + refund.setBucket(LedgerBucket.CYCLE); + refund.setAmountUnits(row.getPaygUnits()); + refund.setReferenceType(ReferenceType.JOB); + refund.setReferenceId(jobId.toString()); + refund.setPolicyId(row.getPolicyId()); + refund.setBillingCategory(category); + ledgerRepository.save(refund); + // Hand back the free units this job consumed (first-step failures are + // pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly + // what was taken at charge time, so the counter can't drift above the grant. + int freeConsumed = + row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed(); + if (freeConsumed > 0 && row.getTeamId() != null) { + teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed); + } + } } } @@ -197,6 +314,142 @@ public class JobChargeService { } } + /** + * Closes a process and — as a fallback — meters its usage. The primary meter trigger is the + * charge interceptor's {@code afterCompletion} on a successful request (see {@link + * #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never + * cleanly completed (request thread died before {@code afterCompletion}) and are swept up later + * by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at + * completion is deduped here at Stripe, so the two paths never double-bill. + * + *

Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which + * silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook + * so a failed POST does not roll back the close; the reconciliation backfill (separate chunk) + * is the durability mechanism. + */ + @Transactional + public ProcessingJob close(UUID jobId) { + Objects.requireNonNull(jobId, "jobId"); + ProcessingJob closed = jobService.close(jobId); + + // The afterCommit hook only fires if there's an active transaction (Spring's + // @Transactional ensures that). If we're called outside one — e.g. a test using the raw + // bean — fall through with a debug log: the close() above already happened in a + // sub-transaction created by JobService, but the surrounding scope has no synchronization. + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + log.debug("close({}): no active synchronization; skipping meter POST", jobId); + return closed; + } + + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + meterJobUsage(jobId); + } catch (RuntimeException e) { + // PaygMeterReportingService should already swallow; defence in depth so + // a thrown exception out of afterCommit doesn't leak past the + // synchronization boundary and bubble into the caller. + log.warn( + "afterCommit meter post for job {} threw unexpectedly: {}", + jobId, + e.getMessage()); + } + } + }); + + return closed; + } + + /** + * Post this job's billable usage to Stripe. The primary caller is the charge interceptor's + * {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes — + * so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code + * afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the + * request thread died); the deterministic idempotency key ({@code process::close}) makes + * the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's + * later stale-closed. + * + *

Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already + * committed by the time either caller runs) and the POST is best-effort. Never throws — see + * {@link PaygMeterReportingService}. + * + *

Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed), + * BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still + * within the app-side free allowance. + */ + public void meterJobUsage(UUID jobId) { + Optional rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId); + if (rowOpt.isEmpty()) { + // No shadow row → not a PAYG-tracked job; nothing to meter. + return; + } + PaygShadowCharge row = rowOpt.get(); + if (row.getStatus() == ShadowChargeStatus.REFUNDED) { + // Refunded rows are zero-net charges; do not emit a meter event. + return; + } + BillingCategory category = row.getBillingCategory(); + if (category == null || category == BillingCategory.BYPASSED) { + // Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before + // openProcess), but tolerate if a future caller writes one. + log.debug("close({}): shadow row category={} → no meter event", jobId, category); + return; + } + Integer units = row.getPaygUnits(); + if (units == null || units <= 0) { + return; + } + Long teamId = row.getTeamId(); + if (teamId == null) { + return; + } + PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null); + if (ext == null) { + return; + } + // payg_subscription_id is the single switch that says "this team is billed" (see + // PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a + // Stripe customer but no live subscription — e.g. the brief window after checkout but + // before the subscription-created webhook lands — must not post meter events against a + // subscription that doesn't exist. A job finishing in that window is still metered later + // via the stale-close fallback, once the subscription has landed (same idempotency key). + String subscriptionId = ext.getPaygSubscriptionId(); + if (subscriptionId == null || subscriptionId.isBlank()) { + log.debug( + "close({}): team {} has no active subscription → no meter event", + jobId, + teamId); + return; + } + String stripeCustomerId = ext.getStripeCustomerId(); + if (stripeCustomerId == null || stripeCustomerId.isBlank()) { + // Subscribed but no customer id is a data inconsistency — we can't address the event. + log.warn( + "close({}): team {} has a subscription but no stripeCustomerId → cannot meter", + jobId, + teamId); + return; + } + + // Paid portion = units beyond the team's one-time free grant, fixed at charge time. The + // free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the + // free units were already withheld when this row's free_units_consumed was set. + int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed(); + int paidUnits = units - freeConsumed; + if (paidUnits <= 0) { + log.debug( + "close({}): all {} units came from the free grant → no meter event", + jobId, + units); + return; + } + String idempotencyKey = "process:" + jobId + ":close"; + meterReportingService.recordUsage( + teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId); + } + /** * Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays * advanced (workflow window intentionally remains active for the next retry). No shadow-row diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java new file mode 100644 index 000000000..1c2ad28dc --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -0,0 +1,347 @@ +package stirling.software.saas.payg.entitlement; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short- + * circuits the request before any handler work happens when the team's snapshot is missing one of + * the gates the route declared via {@link RequiresFeature}. + * + *

Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping} + * (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated + * routes). Admin / info / config endpoints are excluded by the path-pattern in {@code + * PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non- + * billable infra never trips the guard. + * + *

Decision matrix: + * + * + * + * + * + * + * + *
authrequired gatessnapshot enabled?outcome
anonymousAUTOMATION or AI_SUPPORTn/a401 SIGNUP_REQUIRED
anonymousOFFSITE_PROCESSING / CLIENT_SIDEn/a200 (pass through)
authenticatedrequired ⊆ enabledyes200
authenticatedrequired ⊄ enabledno402 FEATURE_DEGRADED
+ * + *

Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap + * pipeline must never block a customer because the guard tripped on a transient DB error. + */ +@Slf4j +@Component +@Profile("saas") +public class EntitlementGuard implements HandlerInterceptor { + + private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING}; + + private final EntitlementService entitlementService; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + private final Counter passCounter; + private final Counter deniedDegradedCounter; + private final Counter deniedPaygLimitCounter; + private final Counter deniedSignupRequiredCounter; + private final Counter errorsCounter; + private final Counter skippedNoAnnotationCounter; + + public EntitlementGuard( + EntitlementService entitlementService, + UserRepository userRepository, + MeterRegistry meterRegistry) { + this.entitlementService = entitlementService; + this.userRepository = userRepository; + this.objectMapper = new ObjectMapper(); + + this.passCounter = + Counter.builder("payg.entitlement.guard") + .tag("outcome", "pass") + .register(meterRegistry); + this.deniedDegradedCounter = + Counter.builder("payg.entitlement.guard") + .tag("outcome", "denied_degraded") + .register(meterRegistry); + this.deniedPaygLimitCounter = + Counter.builder("payg.entitlement.guard") + .tag("outcome", "denied_payg_limit") + .register(meterRegistry); + this.deniedSignupRequiredCounter = + Counter.builder("payg.entitlement.guard") + .tag("outcome", "denied_signup_required") + .register(meterRegistry); + this.skippedNoAnnotationCounter = + Counter.builder("payg.entitlement.guard") + .tag("outcome", "skipped") + .register(meterRegistry); + this.errorsCounter = + Counter.builder("payg.entitlement.guard.errors") + .description("EntitlementGuard internal failures (fail-open)") + .register(meterRegistry); + } + + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod hm)) { + return true; + } + // Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly + // declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping). + // Admin / info / config endpoints carry neither annotation and never trip the guard. + boolean hasAutoJobPostMapping = + AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null + || AnnotationUtils.findAnnotation( + hm.getBeanType(), AutoJobPostMapping.class) + != null; + boolean hasRequiresFeature = + AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null + || AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class) + != null; + if (!hasAutoJobPostMapping && !hasRequiresFeature) { + skippedNoAnnotationCounter.increment(); + return true; + } + + FeatureGate[] required = resolveRequiredGates(hm); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + boolean anonymous = isAnonymous(auth); + boolean billable = isBillable(required); + + if (anonymous) { + if (billable) { + return write401SignupRequired(response, required); + } + // Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only + // charges authenticated requests. + passCounter.increment(); + return true; + } + + Long teamId; + try { + teamId = resolveTeamId(auth); + } catch (RuntimeException e) { + log.warn("EntitlementGuard resolveTeamId failed; passing through", e); + errorsCounter.increment(); + return true; + } + if (teamId == null) { + // Defensive: authenticated principal with no team — shouldn't happen post-migration, + // but we don't want to lock those users out. PaygChargeInterceptor short-circuits the + // same shape upstream. + passCounter.increment(); + return true; + } + + EntitlementSnapshot snapshot; + try { + snapshot = entitlementService.getSnapshot(teamId); + } catch (RuntimeException e) { + log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e); + errorsCounter.increment(); + return true; + } + + // API-key calls are always billable usage (BillingCategory.API) — there is no "free + // manual" path for a programmatic client the way there is for a JWT/web user, whose + // everyday tool calls are BYPASSED and never reach a gate. So once the team is over its + // free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of + // which gate the route declares. The gate loop below would otherwise wave through an API + // call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED), + // letting an unsubscribed team keep consuming the API for free past its allowance. + if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) { + return write402PaygLimitReached(response, snapshot); + } + + List enabled = snapshot.enabledGates(); + for (FeatureGate gate : required) { + if (enabled == null || !enabled.contains(gate)) { + return write402FeatureDegraded(response, required, snapshot); + } + } + passCounter.increment(); + return true; + } + + static FeatureGate[] resolveRequiredGates(HandlerMethod hm) { + RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class); + if (ann == null) { + ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class); + } + if (ann != null && ann.value().length > 0) { + return ann.value(); + } + return DEFAULT_REQUIRED_GATES; + } + + private static boolean isAnonymous(Authentication auth) { + if (auth == null || !auth.isAuthenticated()) { + return true; + } + // Spring's anonymous filter installs a token whose name is "anonymousUser". + return "anonymousUser".equals(auth.getName()); + } + + private static boolean isBillable(FeatureGate[] required) { + for (FeatureGate g : required) { + if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) { + return true; + } + } + return false; + } + + private Long resolveTeamId(Authentication auth) { + if (auth instanceof ApiKeyAuthenticationToken + && auth.getPrincipal() instanceof User apiUser) { + return apiUser.getTeam() == null ? null : apiUser.getTeam().getId(); + } + String supabaseId = AuthenticationUtils.extractSupabaseId(auth); + if (supabaseId == null) { + return null; + } + UUID supabaseUuid; + try { + supabaseUuid = UUID.fromString(supabaseId); + } catch (IllegalArgumentException e) { + // Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip. + return null; + } + return userRepository + .findBySupabaseId(supabaseUuid) + .map(u -> u.getTeam() == null ? null : u.getTeam().getId()) + .orElse(null); + } + + private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) { + deniedSignupRequiredCounter.increment(); + Map body = new LinkedHashMap<>(); + body.put("error", "SIGNUP_REQUIRED"); + body.put("category", inferCategory(required)); + writeJson(response, HttpStatus.UNAUTHORIZED, body); + return false; + } + + /** + * 402 for a billable API-key call once the team is over its allowance / cap. The message is + * tailored by subscription state: an un-subscribed team is told to subscribe (their free + * allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic + * clients get a stable {@code error} code plus the spend/cap numbers so they can surface + * something actionable. + */ + private boolean write402PaygLimitReached( + HttpServletResponse response, EntitlementSnapshot snapshot) { + deniedPaygLimitCounter.increment(); + Map body = new LinkedHashMap<>(); + body.put("error", "PAYG_LIMIT_REACHED"); + body.put("subscribed", snapshot.subscribed()); + body.put( + "message", + snapshot.subscribed() + ? "Your team has reached its monthly spending cap. Raise the cap to" + + " continue, or wait for it to reset next billing period." + : "Your team has used its free document allowance." + + " Subscribe to continue using the API."); + body.put("state", snapshot.state().name()); + body.put("spendUnits", snapshot.periodSpendUnits()); + body.put("capUnits", snapshot.periodCapUnits()); + body.put( + "periodEnd", + Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null)); + writeJson(response, HttpStatus.PAYMENT_REQUIRED, body); + return false; + } + + private boolean write402FeatureDegraded( + HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) { + deniedDegradedCounter.increment(); + Map body = new LinkedHashMap<>(); + body.put("error", "FEATURE_DEGRADED"); + body.put("missingGates", missingGates(required, snapshot.enabledGates())); + body.put("state", snapshot.state().name()); + body.put( + "periodEnd", + Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null)); + body.put("capUnits", snapshot.periodCapUnits()); + body.put("spendUnits", snapshot.periodSpendUnits()); + writeJson(response, HttpStatus.PAYMENT_REQUIRED, body); + return false; + } + + private static List missingGates(FeatureGate[] required, List enabled) { + List enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled; + return Arrays.stream(required) + .filter(g -> !enabledOrEmpty.contains(g)) + .map(Enum::name) + .toList(); + } + + private static String inferCategory(FeatureGate[] required) { + // Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI. + for (FeatureGate g : required) { + if (g == FeatureGate.AUTOMATION) { + return "AUTOMATION"; + } + } + for (FeatureGate g : required) { + if (g == FeatureGate.AI_SUPPORT) { + return "AI"; + } + } + return "OFFSITE_PROCESSING"; + } + + private void writeJson( + HttpServletResponse response, HttpStatus status, Map body) { + response.setStatus(status.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + try { + byte[] payload = objectMapper.writeValueAsBytes(body); + response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length)); + response.getOutputStream().write(payload); + response.getOutputStream().flush(); + } catch (IOException e) { + // Container will fall back to its default error page — we did set the status code, + // so the client still sees the right HTTP code even if the body fails to write. + log.warn("EntitlementGuard write response body failed", e); + errorsCounter.increment(); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java new file mode 100644 index 000000000..397a714bf --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java @@ -0,0 +1,182 @@ +package stirling.software.saas.payg.entitlement; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.cap.CapEvaluator; +import stirling.software.saas.payg.cap.CapEvaluator.Evaluation; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.repository.WalletLedgerRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing + * facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service + * layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top. + * + *

Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value + * #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on + * every instance within that window without coordination. Mutators (wallet policy admin updates, + * subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry + * immediately on the originating instance. + */ +@Slf4j +@Service +@Profile("saas") +public class EntitlementService { + + static final int CACHE_TTL_SECONDS = 30; + private static final int CACHE_MAX_SIZE = 10_000; + + private static final int WARN_AT_PCT = 80; + private static final int DEGRADE_AT_PCT = 100; + + private final TeamBillingService teamBillingService; + private final WalletPolicyRepository walletPolicyRepository; + private final WalletLedgerRepository ledgerRepository; + + private final Cache snapshotCache; + + public EntitlementService( + TeamBillingService teamBillingService, + WalletPolicyRepository walletPolicyRepository, + WalletLedgerRepository ledgerRepository) { + this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService"); + this.walletPolicyRepository = + Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository"); + this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository"); + this.snapshotCache = + Caffeine.newBuilder() + .maximumSize(CACHE_MAX_SIZE) + .expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS)) + .recordStats() + .build(); + } + + /** + * Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value + * #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger. + * + *

{@code null} teamId throws — the guard short-circuits team-less requests upstream so a + * null reach here is a programming error. + */ + public EntitlementSnapshot getSnapshot(Long teamId) { + Objects.requireNonNull(teamId, "teamId"); + return snapshotCache.get(teamId, this::computeSnapshot); + } + + /** + * Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers), + * cap edits, or manual ledger adjustments so the next read recomputes immediately rather than + * waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute + * together with the spend. + */ + public void invalidate(Long teamId) { + if (teamId != null) { + snapshotCache.invalidate(teamId); + teamBillingService.invalidate(teamId); + } + } + + /** Visible for tests. */ + long cacheSize() { + return snapshotCache.estimatedSize(); + } + + @Transactional(readOnly = true) + EntitlementSnapshot computeSnapshot(Long teamId) { + TeamBillingContext billing = teamBillingService.forTeam(teamId); + Optional walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId); + + FeatureSet degradedSet = + walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL); + int warnAtPct = + walletPolicyOpt + .map(WalletPolicy::getWarnAtPct) + .filter(Objects::nonNull) + .orElse(WARN_AT_PCT); + int degradeAtPct = + walletPolicyOpt + .map(WalletPolicy::getDegradeAtPct) + .filter(Objects::nonNull) + .orElse(DEGRADE_AT_PCT); + + // Subscription-anchored window when subscribed; calendar month otherwise. Used for the + // subscribed monthly cap + the displayed billing period. + LocalDateTime periodStart = billing.periodStart(); + LocalDateTime periodEnd = billing.periodEnd(); + + Evaluation eval; + long snapshotSpend; + Long snapshotCap; + + if (billing.subscribed()) { + // Subscribed: gate on the monthly spending cap. Spend = this period's net billable + // documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time + // free grant doesn't gate a paying team — it only reduced what they were metered. + long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd); + long periodSpend = signedNet < 0 ? -signedNet : 0L; + Long cap = billing.monthlyCapDocUnits(); + eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet); + snapshotSpend = periodSpend; + snapshotCap = cap; + } else { + // Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or + // no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate + // the warn/degrade band on used-of-grant. + long grant = billing.freeGrantUnits(); + long remaining = billing.freeRemainingUnits(); + long used = Math.max(0L, grant - remaining); + if (remaining <= 0L) { + eval = + new Evaluation( + EntitlementState.DEGRADED, + degradedSet, + CapEvaluator.gatesFor(degradedSet)); + } else { + eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet); + } + snapshotSpend = used; + snapshotCap = grant; + } + + return new EntitlementSnapshot( + eval.state(), + eval.featureSet(), + List.copyOf(eval.enabledGates()), + snapshotSpend, + snapshotCap, + periodStart, + periodEnd, + billing.subscribed()); + } + + /** + * Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a + * clock value so tests don't race the calendar boundary. The live snapshot window comes from + * {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work. + */ + static LocalDateTime[] currentMonthWindow(LocalDateTime now) { + YearMonth ym = YearMonth.from(now); + LocalDateTime start = ym.atDay(1).atStartOfDay(); + LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay(); + return new LocalDateTime[] {start, end}; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java new file mode 100644 index 000000000..b3d44f3c7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java @@ -0,0 +1,46 @@ +package stirling.software.saas.payg.entitlement; + +import java.time.LocalDateTime; +import java.util.List; + +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link + * EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}. + * + *

Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the persisted + * snapshot that the recompute path writes (one row per team, optionally per member). This record is + * the computed-now view the hot-path guard reads — backed by a 30s Caffeine cache so a + * request burst doesn't hammer the ledger SUM. + * + * @param state aggregate state — FULL, WARNED, or DEGRADED. + * @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED). + * @param enabledGates the gates the guard checks against — request proceeds only if every required + * gate is in this list. + * @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical + * doc-units (positive). + * @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code + * wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped. + * @param periodStart inclusive start of the current cap period. + * @param periodEnd exclusive end of the current cap period. + * @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a + * billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team + * that hit its self-set spending cap is told to raise it. + */ +public record EntitlementSnapshot( + EntitlementState state, + FeatureSet featureSet, + List enabledGates, + long periodSpendUnits, + Long periodCapUnits, + LocalDateTime periodStart, + LocalDateTime periodEnd, + boolean subscribed) { + + public boolean isDegraded() { + return state == EntitlementState.DEGRADED; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index 66aaf5ca7..1006893e3 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -12,6 +12,7 @@ import java.util.Optional; import java.util.UUID; import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.AnnotationUtils; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -37,11 +38,14 @@ import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; import stirling.software.proprietary.security.model.User; +import stirling.software.saas.payg.cap.RequiresFeature; import stirling.software.saas.payg.charge.ChargeContext; import stirling.software.saas.payg.charge.ChargeOutcome; import stirling.software.saas.payg.charge.JobChargeService; import stirling.software.saas.payg.charge.JobInput; import stirling.software.saas.payg.job.JobService; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.FeatureGate; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.JobStepStatus; import stirling.software.saas.payg.model.ProcessType; @@ -52,10 +56,13 @@ import stirling.software.saas.util.AuthenticationUtils; * after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste * work hashing inputs. * - *

{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts, - * materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to - * open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed - * as request attributes for {@code afterCompletion}. + *

{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the + * latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the + * parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link + * JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome} + * plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes + * without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge + * service. * *

{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT * lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or @@ -105,6 +112,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { private final Counter callsOpened; private final Counter callsJoined; private final Counter callsShortCircuit; + private final Counter callsBypassed; private final Counter refundsCounter; /** preHandle wall-clock per request. Separate from afterCompletion — different populations. */ @@ -144,6 +152,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { Counter.builder("payg.filter.calls") .tag("disposition", "SHORT_CIRCUIT") .register(meterRegistry); + this.callsBypassed = + Counter.builder("payg.filter.bypassed") + .description( + "Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)") + .register(meterRegistry); this.refundsCounter = Counter.builder("payg.filter.refunds") .description("First-step 5xx refunds applied to shadow rows") @@ -168,13 +181,39 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { if (!properties.isEnabled()) { return true; } - if (!(handler instanceof HandlerMethod hm) - || hm.getMethodAnnotation(AutoJobPostMapping.class) == null) { + if (!(handler instanceof HandlerMethod hm)) { callsShortCircuit.increment(); return true; } + // In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR + // @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of + // these the interceptor short-circuits — admin / info / static routes never run + // determineCategory. + boolean hasAutoJobPostMapping = + AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null + || AnnotationUtils.findAnnotation( + hm.getBeanType(), AutoJobPostMapping.class) + != null; + boolean hasRequiresFeature = + AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null + || AnnotationUtils.findAnnotation( + hm.getBeanType(), RequiresFeature.class) + != null; + if (!hasAutoJobPostMapping && !hasRequiresFeature) { + callsShortCircuit.increment(); + return true; + } + // Bypass fast-path: determine the BillingCategory BEFORE any multipart + // materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the + // entire ledger/shadow pipeline — no temp files, no DB writes. + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + BillingCategory category = determineCategory(hm, request, auth); + if (category == BillingCategory.BYPASSED) { + callsBypassed.increment(); + return true; + } try { - doPreHandle(request); + doPreHandle(request, auth, category); } catch (RuntimeException e) { log.warn("PAYG preHandle failed; passing through unbilled", e); errorsCounter.increment(); @@ -187,8 +226,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } } - private void doPreHandle(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + private void doPreHandle( + HttpServletRequest request, Authentication auth, BillingCategory category) { User currentUser = resolveUser(auth); if (currentUser == null) { callsShortCircuit.increment(); @@ -248,7 +287,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { currentUser.getId(), currentUser.getTeam() == null ? null : currentUser.getTeam().getId(), determineSource(request, auth), - ProcessType.SINGLE_TOOL); + ProcessType.SINGLE_TOOL, + category); ChargeOutcome outcome; try { @@ -331,12 +371,38 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } if (status >= 400) { // 4xx: customer paid for the attempt. No OUTPUT recording, no refund. + // Still a successful-from-billing-standpoint OPENED process — meter it below. + meterIfOpened(jobId, disposition); return; } + // Success: this is the moment the billable work finished, so this is when we tell Stripe. + // Only the OPENED request meters — JOINED follow-up steps (chained tools on the same + // document) added no units and must not re-meter. The process stays OPEN for further + // lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared + // idempotency key. metering is best-effort and must never break the response teardown. + meterIfOpened(jobId, disposition); recordOutputs(request, response, jobId); } + /** + * Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs + * on the request-teardown thread (the response is already flushed to the client); {@code + * meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a + * meter hiccup can't disturb lineage/cleanup that follows. + */ + private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) { + if (disposition != ChargeOutcome.Disposition.OPENED) { + return; + } + try { + chargeService.meterJobUsage(jobId); + } catch (RuntimeException e) { + log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage()); + errorsCounter.increment(); + } + } + private void recordOutputs( HttpServletRequest request, HttpServletResponse response, UUID jobId) { PaygResponseBodyWrapper wrapper = @@ -445,6 +511,46 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { return JobSource.WEB; } + /** + * Resolve the {@link BillingCategory} for this request. Precedence: {@code + * X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION; + * {@code @RequiresFeature(AI_SUPPORT)} → AI; API-key auth → API; otherwise BYPASSED (manual UI + * tool — short-circuited in {@link #preHandle}). + * + *

Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION + * dominates AI within a single annotation. + */ + private static BillingCategory determineCategory( + HandlerMethod handler, HttpServletRequest request, Authentication auth) { + String automationHeader = request.getHeader(AUTOMATION_HEADER); + if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { + return BillingCategory.AUTOMATION; + } + RequiresFeature ann = + AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class); + if (ann == null) { + ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class); + } + if (ann != null) { + boolean ai = false; + for (FeatureGate gate : ann.value()) { + if (gate == FeatureGate.AUTOMATION) { + return BillingCategory.AUTOMATION; + } + if (gate == FeatureGate.AI_SUPPORT) { + ai = true; + } + } + if (ai) { + return BillingCategory.AI; + } + } + if (auth instanceof ApiKeyAuthenticationToken) { + return BillingCategory.API; + } + return BillingCategory.BYPASSED; + } + /** * Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route * pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java index 8b29874bc..30dd5a4ba 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java @@ -9,6 +9,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import lombok.RequiredArgsConstructor; +import stirling.software.saas.payg.entitlement.EntitlementGuard; + /** * Wires the PAYG filter + interceptor into Spring MVC. Two registrations: * @@ -28,6 +30,7 @@ import lombok.RequiredArgsConstructor; public class PaygWebMvcConfig implements WebMvcConfigurer { private final PaygChargeInterceptor paygChargeInterceptor; + private final EntitlementGuard entitlementGuard; @Bean public FilterRegistrationBean @@ -46,6 +49,16 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { */ public static final int INTERCEPTOR_ORDER = 1000; + /** + * The entitlement guard runs AFTER the charge interceptor so cap-rejected requests still leave + * the charge interceptor's preHandle state in the consistent open-or-bypassed shape (and so the + * guard's 402 reaches the client without the charge interceptor needing to know about it). + * Spring runs interceptors in registration order on the way in, reverse order on the way out; + * the guard's preHandle short-circuit ({@code return false}) prevents the handler from running + * but Spring still invokes the charge interceptor's afterCompletion to clean up temp files. + */ + public static final int ENTITLEMENT_GUARD_ORDER = 1100; + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(paygChargeInterceptor) @@ -56,5 +69,14 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { "/api/v1/info/**", "/api/v1/admin/**") .order(INTERCEPTOR_ORDER); + + registry.addInterceptor(entitlementGuard) + .addPathPatterns("/api/**") + .excludePathPatterns( + "/api/v1/credits/**", + "/api/v1/config/**", + "/api/v1/info/**", + "/api/v1/admin/**") + .order(ENTITLEMENT_GUARD_ORDER); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java index 6c4813988..432965aa7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java @@ -1,34 +1,69 @@ package stirling.software.saas.payg.job; +import java.util.List; + import org.springframework.context.annotation.Profile; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.saas.payg.charge.JobChargeService; + /** * Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs * every minute. API users never have to call {@code close()} explicitly — this scheduler is the - * safety net. + * safety net, and (for metered teams) the point at which the Stripe meter event is posted. + * + *

Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)} + * rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code + * JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to + * Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter + * them — usage would accrue in the wallet ledger yet never reach the customer's invoice. + * + *

Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own + * transaction (cross-bean proxied call from this non-transactional scheduled method), so the + * afterCommit meter POST fires once per job and one job's failure can't abort the rest of the + * sweep. The meter event's idempotency key ({@code process::close}) makes a re-run on the next + * tick safe even if a close half-completed. * *

Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other - * {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod - * double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design - * § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate - * firings read an empty stale set on the second pod, no data corruption risk. + * {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is + * tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a + * double-fire across pods reads a shrinking stale set and never double-bills. */ @Component @Profile("saas") -@RequiredArgsConstructor @Slf4j public class StaleJobCloser { private final JobService jobService; + private final JobChargeService chargeService; + + public StaleJobCloser(JobService jobService, JobChargeService chargeService) { + this.jobService = jobService; + this.chargeService = chargeService; + } @Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}") public void closeStale() { - int closed = jobService.closeStale(); + List stale = jobService.findStale(); + if (stale.isEmpty()) { + return; + } + int closed = 0; + for (ProcessingJob job : stale) { + try { + // Routes through the charge service so the afterCommit meter hook fires for + // metered teams. Idempotent: a job already closed by a racing tick no-ops. + chargeService.close(job.getId()); + closed++; + } catch (RuntimeException e) { + // Isolate per job — a single bad row (or a transient meter-path issue) must not + // strand the rest of the stale set open. Next tick retries. + log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage()); + } + } if (closed > 0) { log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java new file mode 100644 index 000000000..e19cf522e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java @@ -0,0 +1,69 @@ +package stirling.software.saas.payg.meter; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log}, + * V15). A row is written pending ({@code posted_to_stripe_at} NULL) just before the POST + * and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows + * still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is + * the durability mechanism behind the fail-open meter path, so a Stripe blip never silently + * under-bills. + * + *

{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code + * process::close}); the unique constraint gives safe at-least-once semantics across the dual + * meter triggers (completion + stale-close) and reconcile retries. + */ +@Entity +@Table(name = "payg_meter_event_log") +@Getter +@Setter +@NoArgsConstructor +public class PaygMeterEventLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_id") + private Long eventId; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "job_id") + private UUID jobId; + + @Column(name = "idempotency_key", nullable = false, unique = true, length = 128) + private String idempotencyKey; + + @Column(name = "units", nullable = false) + private Integer units; + + /** + * Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code + * insertPending}). + */ + @Column(name = "occurred_at", nullable = false, insertable = false, updatable = false) + private LocalDateTime occurredAt; + + /** NULL while pending; stamped when the meter-payg-units edge fn returns success. */ + @Column(name = "posted_to_stripe_at") + private LocalDateTime postedToStripeAt; + + @Column(name = "stripe_error_code", length = 64) + private String stripeErrorCode; + + @Column(name = "stripe_error_body", columnDefinition = "text") + private String stripeErrorBody; +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java new file mode 100644 index 000000000..81cde551f --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java @@ -0,0 +1,139 @@ +package stirling.software.saas.payg.meter; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; + +/** + * Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability + * half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code + * payg_meter_event_log} row before each POST and stamps it on success; anything left unposted + * (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent + * under the same idempotency key, so Stripe dedups rather than double-charging. + * + *

Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no + * longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than + * risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link + * stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a + * duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe — + * idempotent, wasted IO at worst. + */ +@Component +@Profile("saas") +@Slf4j +public class PaygMeterReconcileScheduler { + + /** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */ + private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24); + + private final PaygMeterEventLogRepository eventLogRepository; + private final PaygTeamExtensionsRepository teamExtensionsRepository; + private final PaygMeterReportingService meterReportingService; + private final boolean enabled; + private final Duration retryDelay; + private final int batchSize; + private final Counter retriedCounter; + + public PaygMeterReconcileScheduler( + PaygMeterEventLogRepository eventLogRepository, + PaygTeamExtensionsRepository teamExtensionsRepository, + PaygMeterReportingService meterReportingService, + @Value("${payg.meter.reconcile.enabled:true}") boolean enabled, + @Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay, + @Value("${payg.meter.reconcile.batch-size:100}") int batchSize, + MeterRegistry meterRegistry) { + this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository"); + this.teamExtensionsRepository = + Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository"); + this.meterReportingService = + Objects.requireNonNull(meterReportingService, "meterReportingService"); + this.enabled = enabled; + this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay"); + this.batchSize = batchSize > 0 ? batchSize : 100; + this.retriedCounter = + Counter.builder("payg.meter.reconcile.retried") + .description("PAYG meter events re-posted to Stripe by the reconcile job") + .register(meterRegistry); + } + + @Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC") + public void reconcile() { + if (!enabled) { + return; + } + LocalDateTime now = LocalDateTime.now(); + // Give the live POST a moment to land before retrying; stay inside the 24h dedup window. + LocalDateTime cutoff = now.minus(retryDelay); + LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW); + + List retryable = + eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize)); + + // Batch-fetch this page's team extensions in one query (keyed by team id) rather than a + // findById per row — avoids an N+1 when the page spans several teams. + List teamIds = + retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList(); + Map extById = + teamExtensionsRepository.findAllById(teamIds).stream() + .collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext)); + + int retried = 0; + for (PaygMeterEventLog row : retryable) { + PaygTeamExtensions ext = extById.get(row.getTeamId()); + if (ext == null) { + continue; + } + String subscriptionId = ext.getPaygSubscriptionId(); + String stripeCustomerId = ext.getStripeCustomerId(); + if (subscriptionId == null + || subscriptionId.isBlank() + || stripeCustomerId == null + || stripeCustomerId.isBlank()) { + // Team unsubscribed since the event was logged — nothing to bill; leave the row. + continue; + } + // Same idempotency key → Stripe dedups if the original actually landed. recordUsage + // re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is + // not re-derived (analytics metadata only); units + key are what bill. + meterReportingService.recordUsage( + row.getTeamId(), + stripeCustomerId, + row.getUnits() == null ? 0 : row.getUnits(), + null, + row.getIdempotencyKey(), + row.getJobId()); + retried++; + } + if (retried > 0) { + retriedCounter.increment(retried); + log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried); + } + + long stuck = eventLogRepository.countStuck(floor); + if (stuck > 0) { + log.warn( + "{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —" + + " manual reconciliation needed.", + stuck, + STRIPE_IDEMPOTENCY_WINDOW.toHours()); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java new file mode 100644 index 000000000..e578edd15 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java @@ -0,0 +1,221 @@ +package stirling.software.saas.payg.meter; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; + +/** + * POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from + * {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the + * customer's authoritative bill) is already durable before we tell Stripe about it. + * + *

Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for + * analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call + * entirely; the ledger entry is the only record needed. + * + *

Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log + * WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code + * payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link + * PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code + * close()} must not roll back because Stripe wobbled. + * + *

Both config keys default to empty so unit tests / local dev never crash on missing env. When + * blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to + * touch the real edge function. + */ +@Service +@Profile("saas") +@Slf4j +public class PaygMeterReportingService { + + private final String endpoint; + private final String authToken; + private final RestTemplate restTemplate; + private final PaygMeterEventLogRepository eventLogRepository; + private final Counter errorsCounter; + + /** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */ + private static final int MAX_ERROR_BODY = 4000; + + public PaygMeterReportingService( + @Value("${payg.meter.endpoint:}") String endpoint, + @Value("${payg.meter.auth-token:}") String authToken, + RestTemplate saasRestTemplate, + PaygMeterEventLogRepository eventLogRepository, + MeterRegistry meterRegistry) { + this.endpoint = endpoint; + this.authToken = authToken; + this.restTemplate = saasRestTemplate; + this.eventLogRepository = eventLogRepository; + this.errorsCounter = + Counter.builder("payg.meter.errors") + .description("Failures POSTing PAYG meter events to Supabase edge function") + .register(meterRegistry); + } + + /** + * Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on + * the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g. + * {@code "process::close"}) so a retry, a reconcile replay, or a double-fire from two + * pods never charges twice. + * + *

Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn, + * then stamp the row posted or record the Stripe error. The row is what {@link + * PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently + * dropped. + * + *

Never throws. The wallet ledger entry is the source of truth for what the customer is + * billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile + * retries it. + */ + public void recordUsage( + Long teamId, + String stripeCustomerId, + int units, + BillingCategory category, + String idempotencyKey, + UUID jobId) { + if (endpoint == null || endpoint.isBlank()) { + log.debug( + "payg.meter.endpoint not configured; skipping meter event for team {} key {}", + teamId, + idempotencyKey); + return; + } + if (units <= 0) { + // Zero-unit events would inflate event count without changing the bill — defensive. + log.debug( + "Skipping meter event with units={} for team {} key {}", + units, + teamId, + idempotencyKey); + return; + } + + // Durable pending row before the POST so a failure leaves a record the reconcile scheduler + // can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close + // triggers and reconcile retries all share the key. Best-effort: a logging failure must + // never stop us from actually metering. + try { + eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units); + } catch (Exception e) { + log.warn( + "payg_meter_event_log pending insert failed for key {} (still metering): {}", + idempotencyKey, + e.getMessage()); + } + + PostOutcome outcome = + postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey); + + try { + if (outcome.success()) { + eventLogRepository.markPosted(idempotencyKey); + } else { + eventLogRepository.markFailed( + idempotencyKey, outcome.errorCode(), outcome.errorBody()); + } + } catch (Exception e) { + log.warn( + "payg_meter_event_log result update failed for key {}: {}", + idempotencyKey, + e.getMessage()); + } + } + + /** + * POST the event to the edge fn. Never throws; returns success / the captured Stripe error. + * Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract). + */ + private PostOutcome postToStripe( + Long teamId, + String stripeCustomerId, + int units, + BillingCategory category, + String idempotencyKey) { + try { + HttpHeaders headers = new HttpHeaders(); + if (authToken != null && !authToken.isBlank()) { + headers.setBearerAuth(authToken); + } + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = + Map.of( + "team_id", + // JSON number — the edge fn type-checks and ignores strings. + teamId == null ? -1L : teamId, + "stripe_customer_id", + stripeCustomerId == null ? "" : stripeCustomerId, + "units", + units, + "idempotency_key", + idempotencyKey, + "metadata", + Map.of("category", category == null ? "UNKNOWN" : category.name())); + + ResponseEntity response = + restTemplate.exchange( + endpoint, + HttpMethod.POST, + new HttpEntity<>(body, headers), + String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + return PostOutcome.ok(); + } + log.warn( + "Meter event POST returned {} for team {} key {}: {}", + response.getStatusCode(), + teamId, + idempotencyKey, + response.getBody()); + errorsCounter.increment(); + return PostOutcome.error( + String.valueOf(response.getStatusCode().value()), response.getBody()); + } catch (Exception e) { + // Catch-all by design: this method MUST NOT propagate. The customer's bill via the + // ledger is correct; we just owe Stripe an event the reconcile scheduler will retry. + log.warn( + "Meter event POST failed for team {} key {}: {}", + teamId, + idempotencyKey, + e.getMessage()); + errorsCounter.increment(); + return PostOutcome.error("exception", e.getMessage()); + } + } + + /** Outcome of one edge-fn POST attempt. */ + private record PostOutcome(boolean success, String errorCode, String errorBody) { + static PostOutcome ok() { + return new PostOutcome(true, null, null); + } + + static PostOutcome error(String code, String body) { + String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code; + String trimmedBody = + body != null && body.length() > MAX_ERROR_BODY + ? body.substring(0, MAX_ERROR_BODY) + : body; + return new PostOutcome(false, trimmedCode, trimmedBody); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java new file mode 100644 index 000000000..2b1c0fee7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java @@ -0,0 +1,18 @@ +package stirling.software.saas.payg.model; + +/** + * Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG + * stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing. + * + *

Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link + * #BYPASSED} is the default for manual UI tool calls that never hit a billable code path. + * + *

Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream + * relies on {@code ordinal()}. + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java index 7d6f1b402..c564f8556 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java @@ -71,6 +71,16 @@ public class PaygTeamExtensions implements Serializable { @Column(name = "payg_subscription_id", unique = true, length = 128) private String paygSubscriptionId; + /** + * Remaining one-time free documents for this team (the lifetime grant). Seeded from the + * effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger, + * updated in V19); decremented by the charge pipeline when a billable charge is written and + * restored on a first-step refund. Never replenishes; survives subscribing. This counter — not + * the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned. + */ + @Column(name = "free_units_remaining", nullable = false) + private Long freeUnitsRemaining = 0L; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java index 743dcdc8a..958117736 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -74,18 +74,14 @@ public class PricingPolicy implements Serializable { private Integer fileUnitCap = 1000; /** - * Free-tier allowance — doc units a team on this policy can consume per cycle before they must - * add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their - * very first tool call until they pay. New-signup defaults will set this to a sensible positive - * value; the special launch policy used by the day-1 legacy migration script can override with - * a different size if product wants the original 10 customers to feel special. - * - *

Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code - * payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass - * this check entirely. + * One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means + * no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after + * subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when + * the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team + * counter is authoritative and this column is only the seed for new teams. */ - @Column(name = "free_tier_units_per_cycle", nullable = false) - private Long freeTierUnitsPerCycle = 0L; + @Column(name = "free_tier_units", nullable = false) + private Long freeTierUnits = 0L; /** * Max tool steps allowed in one process before it splits, keyed by the caller's {@link diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java new file mode 100644 index 000000000..7d752bd14 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java @@ -0,0 +1,81 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.saas.payg.meter.PaygMeterEventLog; + +@Repository +public interface PaygMeterEventLogRepository extends JpaRepository { + + /** + * Idempotent pending insert. The two meter triggers (charge-completion + stale-close) share the + * idempotency key, and a reconcile retry re-runs the same path, so {@code ON CONFLICT DO + * NOTHING} keeps the audit row at exactly one per key. {@code occurred_at} defaults to {@code + * CURRENT_TIMESTAMP} at the DB; {@code posted_to_stripe_at} stays NULL until success. + * Transactional because it's called from the non-transactional meter-reporting path. + */ + @Transactional + @Modifying + @Query( + value = + "INSERT INTO payg_meter_event_log (team_id, job_id, idempotency_key, units)" + + " VALUES (:teamId, :jobId, :key, :units)" + + " ON CONFLICT (idempotency_key) DO NOTHING", + nativeQuery = true) + void insertPending( + @Param("teamId") Long teamId, + @Param("jobId") UUID jobId, + @Param("key") String key, + @Param("units") int units); + + /** Stamp an event posted; clears any prior error. No-op if already posted. */ + @Transactional + @Modifying + @Query( + "UPDATE PaygMeterEventLog e" + + " SET e.postedToStripeAt = CURRENT_TIMESTAMP," + + " e.stripeErrorCode = NULL, e.stripeErrorBody = NULL" + + " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL") + int markPosted(@Param("key") String key); + + /** Record the latest Stripe error against a still-pending event. */ + @Transactional + @Modifying + @Query( + "UPDATE PaygMeterEventLog e" + + " SET e.stripeErrorCode = :code, e.stripeErrorBody = :body" + + " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL") + int markFailed( + @Param("key") String key, @Param("code") String code, @Param("body") String body); + + /** + * Events still unposted whose attempt is older than {@code cutoff} (give the live POST time to + * land first) but within {@code floor} — Stripe's 24h idempotency window — so a retry under the + * same key safely dedups rather than risking a double charge. Oldest first. + */ + @Query( + "SELECT e FROM PaygMeterEventLog e" + + " WHERE e.postedToStripeAt IS NULL" + + " AND e.occurredAt < :cutoff AND e.occurredAt >= :floor" + + " ORDER BY e.occurredAt ASC") + List findRetryable( + @Param("cutoff") LocalDateTime cutoff, + @Param("floor") LocalDateTime floor, + Pageable pageable); + + /** Events stuck unposted past the safe retry window — surfaced for manual reconciliation. */ + @Query( + "SELECT COUNT(e) FROM PaygMeterEventLog e" + + " WHERE e.postedToStripeAt IS NULL AND e.occurredAt < :floor") + long countStuck(@Param("floor") LocalDateTime floor); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java index e953285ee..8a2911f9c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java @@ -29,4 +29,20 @@ public interface PaygShadowChargeRepository extends JpaRepository findFirstByJobIdOrderByIdAsc(UUID jobId); + + /** + * Paid (Stripe-metered) documents for a team in a period: {@code SUM(payg_units − + * free_units_consumed)} over CHARGED rows. This is exactly what was reported to Stripe in the + * window, so the wallet's "estimated bill so far" is the metered total × rate. REFUNDED rows + * are excluded. + */ + @Query( + "SELECT COALESCE(SUM(s.paygUnits - s.freeUnitsConsumed), 0) FROM PaygShadowCharge s" + + " WHERE s.teamId = :teamId" + + " AND s.status = stirling.software.saas.payg.model.ShadowChargeStatus.CHARGED" + + " AND s.occurredAt >= :from AND s.occurredAt < :to") + long sumPaidUnits( + @Param("teamId") Long teamId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java index 3473eeeff..35fb6bbf7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java @@ -3,12 +3,40 @@ package stirling.software.saas.payg.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import jakarta.persistence.LockModeType; + import stirling.software.saas.payg.policy.PaygTeamExtensions; @Repository public interface PaygTeamExtensionsRepository extends JpaRepository { Optional findByStripeCustomerId(String stripeCustomerId); + + /** + * Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time + * free grant atomically. The lock serialises concurrent charges for the same team so + * the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is + * exact — two simultaneous jobs can't both believe they drew from the same remaining unit. + * Different teams never contend; the lock is held only for the {@code openProcess} transaction. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId") + Optional findByIdForUpdate(@Param("teamId") Long teamId); + + /** + * Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is + * commutative so no lock is needed; the amount restored is exactly what the job consumed, so it + * can never exceed the original grant. + */ + @Modifying + @Query( + "UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units" + + " WHERE e.teamId = :teamId") + int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java index 2a2a00342..095f6cb92 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -14,7 +14,30 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry; @Repository public interface WalletLedgerRepository extends JpaRepository { - List findByTeamIdOrderByOccurredAtDesc(Long teamId); + /** Most recent entries for the Plan page activity feed. */ + List findTop20ByTeamIdOrderByIdDesc(Long teamId); + + /** + * Per-category debit totals over an arbitrary window, as positive units. Replaces the + * calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed + * teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows + * with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger + * by construction. + */ + @Query( + "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units" + + " FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId" + + " AND e.entryType = :entryType" + + " AND e.billingCategory IS NOT NULL" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd" + + " GROUP BY e.billingCategory") + List sumPeriodAmountByCategory( + @Param("teamId") Long teamId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); /** Sum of signed amounts over a team's entries — the wallet's current balance in units. */ @Query( @@ -34,6 +57,24 @@ public interface WalletLedgerRepository extends JpaRepository= :periodStart" + + " AND e.occurredAt < :periodEnd") + long sumPeriodNetBillable( + @Param("teamId") Long teamId, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); + /** Per-member period spend (only when the member has a sub-cap configured). */ @Query( "SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e" diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java index b2b8ed0c2..57aea1a0e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -19,6 +19,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.ShadowChargeStatus; /** @@ -56,6 +58,15 @@ public class PaygShadowCharge implements Serializable { @Column(name = "payg_units", nullable = false) private Integer paygUnits; + /** + * How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time. + * The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores + * this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19 + * rows and for jobs that consumed no free units (team's grant already exhausted). + */ + @Column(name = "free_units_consumed", nullable = false) + private Integer freeUnitsConsumed = 0; + @Column(name = "legacy_credits_charged", nullable = false) private Integer legacyCreditsCharged; @@ -75,6 +86,24 @@ public class PaygShadowCharge implements Serializable { @Column(name = "refund_reason", length = 128) private String refundReason; + /** + * PAYG analytics axis copied from the request that produced this shadow row. {@code null} for + * pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe + * would meter — the flat-meter assumption holds, this is breakdown metadata. + */ + @Enumerated(EnumType.STRING) + @Column(name = "billing_category", length = 16) + private BillingCategory billingCategory; + + /** + * Caller surface that originated the job (copied from {@code processing_job.source} at write + * time so the row stays self-describing after the job table is pruned). {@code null} for + * pre-V16 rows backfilled when {@code processing_job} is no longer present. + */ + @Enumerated(EnumType.STRING) + @Column(name = "job_source", length = 32) + private JobSource jobSource; + @CreationTimestamp @Column(name = "occurred_at", nullable = false, updatable = false) private LocalDateTime occurredAt; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java new file mode 100644 index 000000000..9c0f3f2b4 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java @@ -0,0 +1,215 @@ +package stirling.software.saas.payg.stripe; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import lombok.extern.slf4j.Slf4j; + +/** + * Read-only accessor for the Stripe Sync Engine schema ({@code stripe.*}). Gives the PAYG layer the + * team's real billing window and the per-document rate of the Price its subscription bills against + * — both live in Stripe, mirrored into Postgres by the sync engine, and are NOT duplicated in + * {@code stirling_pdf} (design §10: money lives in Stripe). + * + *

PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount} + * carries the rate directly. The free grant is deliberately NOT in Stripe — it's the one-time + * {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered), + * because un-subscribed teams get the same grant and have no Stripe Price at all. + * + *

Defensive by construction: the {@code stripe} schema only exists where the sync engine has run + * (dev + prod Supabase, not unit-test H2). Any {@link DataAccessException} — missing schema, + * missing row, connectivity blip — degrades to {@link Optional#empty()} with a WARN so callers fall + * back to calendar-month windows rather than 500ing the wallet endpoint. + */ +@Slf4j +@Repository +@Profile("saas") +public class StripeSubscriptionDao { + + /** + * Billing window + per-document rate of one subscription. Epoch seconds come back from sync + * engine as INTEGER columns; converted to {@link LocalDateTime} in the system zone so they + * compare cleanly against {@code wallet_ledger.occurred_at} (written by + * {@code @CreationTimestamp} with JVM-local semantics). + * + * @param priceId the Stripe Price the subscription's (sole) item bills against; null if the + * item row hasn't synced yet + * @param currency lower-case ISO 4217 of that Price; null when the price row is missing + * @param perDocMinor per-document rate in minor units (may be fractional via {@code + * unit_amount_decimal}); null when the price row is missing or carries no usable amount + * (e.g. a tiered price, which PAYG doesn't use) + */ + public record SubscriptionBilling( + LocalDateTime periodStart, + LocalDateTime periodEnd, + String priceId, + String status, + String currency, + BigDecimal perDocMinor) {} + + /** + * The per-document rate of a Price looked up directly (not via a subscription) — used to price + * the cap estimate for un-subscribed teams, whose default policy points at Stripe Prices that + * carry the same {@code unit_amount} they'd be billed at on subscribing. + * + * @param priceId the resolved Stripe Price id + * @param currency lower-case ISO 4217 of that Price + * @param perDocMinor per-document rate in minor units (may be fractional); never null — a row + * with no usable amount is filtered out rather than returned with a null rate + */ + public record PriceRate(String priceId, String currency, BigDecimal perDocMinor) {} + + private static final String QUERY = + "SELECT s.current_period_start, s.current_period_end, s.status::text AS status," + + " p.id AS price_id, p.currency AS currency," + + " p.unit_amount AS unit_amount, p.unit_amount_decimal AS unit_amount_decimal" + + " FROM stripe.subscriptions s" + + " LEFT JOIN LATERAL (" + + " SELECT si.price FROM stripe.subscription_items si" + + " WHERE si.subscription = s.id AND COALESCE(si.deleted, false) = false" + + " ORDER BY si.created DESC NULLS LAST LIMIT 1" + + " ) item ON true" + + " LEFT JOIN stripe.prices p ON p.id = item.price" + + " WHERE s.id = ?"; + + private final JdbcTemplate jdbcTemplate; + + public StripeSubscriptionDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + /** Billing window + rate for {@code subscriptionId}; empty if unsynced or schema absent. */ + public Optional findBilling(String subscriptionId) { + if (subscriptionId == null || subscriptionId.isBlank()) { + return Optional.empty(); + } + try { + List rows = + jdbcTemplate.query( + QUERY, + (rs, i) -> { + long startEpoch = rs.getLong("current_period_start"); + boolean startNull = rs.wasNull(); + long endEpoch = rs.getLong("current_period_end"); + boolean endNull = rs.wasNull(); + if (startNull || endNull) { + return null; + } + return new SubscriptionBilling( + toLocal(startEpoch), + toLocal(endEpoch), + rs.getString("price_id"), + rs.getString("status"), + rs.getString("currency"), + extractRate(rs)); + }, + subscriptionId); + return rows.stream().filter(Objects::nonNull).findFirst(); + } catch (DataAccessException e) { + // Missing stripe schema (sync engine not provisioned) or transient DB issue. The + // caller falls back to a calendar-month window; usage still accrues correctly. + log.warn( + "stripe.subscriptions lookup failed for {}: {}", + subscriptionId, + e.getMessage()); + return Optional.empty(); + } + } + + /** + * Per-document rate of the active {@code stripe.prices} row with the given {@code lookupKey} in + * {@code currency} — the elegant mirror of {@link #findBilling}, reading the same synced table + * by Stripe Price {@code lookup_key} instead of via a subscription. The PAYG layer uses this to + * price the cap estimate for an un-subscribed team: there's no subscription to read a rate off, + * but the PAYG Price (lookup key {@code plan:processor}) carries the very rate they'd be billed + * at. We resolve by lookup_key rather than the default policy's price ids because those aren't + * seeded — the lookup key is the stable, env-agnostic handle (same one the price-lookup edge + * function uses). + * + *

Empty when the {@code stripe} schema is absent (H2 unit tests), no active matching row + * exists, or the row carries no usable per-unit amount (e.g. a tiered price, which PAYG doesn't + * use). Callers degrade to "no estimate" exactly as the subscribed path degrades to a + * calendar-month window. + */ + public Optional findRateByLookupKey(String lookupKey, String currency) { + if (lookupKey == null || lookupKey.isBlank() || currency == null || currency.isBlank()) { + return Optional.empty(); + } + String sql = + "SELECT p.id AS price_id, p.currency AS currency," + + " p.unit_amount AS unit_amount," + + " p.unit_amount_decimal AS unit_amount_decimal" + + " FROM stripe.prices p" + + " WHERE p.lookup_key = ? AND p.currency = ?" + + " AND COALESCE(p.active, true) = true" + + " ORDER BY p.created DESC NULLS LAST LIMIT 1"; + try { + List rows = + jdbcTemplate.query( + sql, + (rs, i) -> { + BigDecimal rate = extractRate(rs); + if (rate == null) { + return null; + } + return new PriceRate( + rs.getString("price_id"), rs.getString("currency"), rate); + }, + lookupKey, + currency.toLowerCase()); + return rows.stream().filter(Objects::nonNull).findFirst(); + } catch (DataAccessException e) { + log.warn( + "stripe.prices rate lookup failed for lookup_key {} / currency {}: {}", + lookupKey, + currency, + e.getMessage()); + return Optional.empty(); + } + } + + /** + * Reads the per-document rate off a {@code stripe.prices} row. Prefers {@code + * unit_amount_decimal} (sub-minor-unit precision, e.g. half-cent rates), falls back to the + * integer {@code unit_amount}, and returns null when neither is usable or the amount is ≤ 0 + * (tiered/zero prices PAYG doesn't bill on). Both queries alias the columns identically so this + * is shared verbatim. + */ + private static BigDecimal extractRate(ResultSet rs) throws SQLException { + BigDecimal rate = null; + String decimal = rs.getString("unit_amount_decimal"); + if (decimal != null && !decimal.isBlank()) { + try { + rate = new BigDecimal(decimal); + } catch (NumberFormatException ignore) { + rate = null; + } + } + if (rate == null) { + long unitAmount = rs.getLong("unit_amount"); + if (!rs.wasNull()) { + rate = BigDecimal.valueOf(unitAmount); + } + } + if (rate != null && rate.signum() <= 0) { + rate = null; + } + return rate; + } + + private static LocalDateTime toLocal(long epochSeconds) { + return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 90aed63cd..4971e296f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -22,6 +22,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.LedgerBucket; import stirling.software.saas.payg.model.LedgerEntryType; import stirling.software.saas.payg.model.ReferenceType; @@ -76,6 +77,15 @@ public class WalletLedgerEntry implements Serializable { @Column(name = "stripe_event_id", length = 128) private String stripeEventId; + /** + * PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set + * by the charge interceptor for billable debits. Stripe pricing never reads this — it's a + * single flat meter — so the column stays soft-typed (no NOT NULL, no FK). + */ + @Enumerated(EnumType.STRING) + @Column(name = "billing_category", length = 16) + private BillingCategory billingCategory; + @CreationTimestamp @Column(name = "occurred_at", nullable = false, updatable = false) private LocalDateTime occurredAt; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java index b3231224d..d88d96cfc 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java @@ -42,6 +42,24 @@ public interface TeamMembershipRepository extends JpaRepository findByUserId(@Param("userId") Long userId); + /** + * Resolve the single membership a user belongs to. In the PAYG design every user is owned by + * exactly one team — personal-team-for-new-signups, then optionally migrated when they accept + * an invite (the old personal team is deleted on accept). For diagnostic safety this picks the + * earliest-created row if multiple exist, but in steady state there is exactly one. + * + *

Returns both the team and its role so the PAYG wallet endpoint can answer "what does this + * user see?" in a single query rather than a list-then-filter dance. + * + * @param userId the user ID + * @return the user's primary membership, if any + */ + @Query( + "SELECT tm FROM TeamMembership tm JOIN FETCH tm.team" + + " WHERE tm.user.id = :userId" + + " ORDER BY tm.createdAt ASC") + List findPrimaryMembership(@Param("userId") Long userId); + /** * Find all members with a specific role in a team * diff --git a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java index 49a0042b1..c01f3d034 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java @@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository { + " END, " + " total_api_calls_made = total_api_calls_made + 1, " + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " + " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)", nativeQuery = true) int consumeCreditBySupabaseId( @@ -109,7 +109,7 @@ public interface UserCreditRepository extends JpaRepository { + " cycle_credits_remaining = cycle_credits_remaining - :amount, " + " total_api_calls_made = total_api_calls_made + 1, " + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " + " AND cycle_credits_remaining >= :amount", nativeQuery = true) int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -123,7 +123,7 @@ public interface UserCreditRepository extends JpaRepository { + " bought_credits_remaining = bought_credits_remaining - :amount, " + " total_api_calls_made = total_api_calls_made + 1, " + " last_api_usage = now() " - + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) " + + "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) " + " AND bought_credits_remaining >= :amount", nativeQuery = true) int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -133,7 +133,7 @@ public interface UserCreditRepository extends JpaRepository { value = "SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END " + "FROM user_credits uc " - + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)", + + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)", nativeQuery = true) Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); @@ -142,7 +142,7 @@ public interface UserCreditRepository extends JpaRepository { value = "SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END " + "FROM user_credits uc " - + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)", + + "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)", nativeQuery = true) Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount); } diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties index 725fe79fe..ee8bf80ff 100644 --- a/app/saas/src/main/resources/application-dev.properties +++ b/app/saas/src/main/resources/application-dev.properties @@ -23,3 +23,10 @@ spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consoli logging.level.stirling.software.saas=DEBUG logging.level.org.springframework.security.oauth2.jwt=WARN logging.level.org.springframework.security.oauth2.server.resource=WARN + +# Supabase meter edge fn the Java backend calls (server-to-server, on job close). +# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same +# shared secret the team-invitation flow uses — no service-role key in the Java env). +# Blank secret → the meter service no-ops with a WARN, so the app still boots. +# The billing portal is NOT here — the FE calls create-customer-portal-session directly. +payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 3676ad7d8..71e65de65 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -35,6 +35,31 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120} app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1 app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:} +# ---------- PAYG meter reporting ---------- +# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService +# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set +# PAYG_METER_ENDPOINT in deployed envs to enable. Compose with the Supabase functions base via +# env (e.g. PAYG_METER_ENDPOINT=$SUPABASE_FUNCTIONS_URL/meter-payg-units) rather than templating +# here — concatenating an empty default would produce a half-valid URL. +# Auth rides the existing backend<->edge-fn shared secret (SUPABASE_EDGE_FUNCTION_SECRET, same +# one team-invitation-email uses) — the backend never holds the RLS-bypassing service-role key. +payg.meter.endpoint=${PAYG_METER_ENDPOINT:} +payg.meter.auth-token=${app.supabase.edge-function-secret:} + +# Reconcile job: retries meter events logged to payg_meter_event_log but never confirmed posted +# (Stripe blip, edge-fn outage, pod crash mid-POST). Re-sends under the same idempotency key so +# Stripe dedups; only within Stripe's 24h window. Defaults are sensible; tune/disable via env. +payg.meter.reconcile.enabled=${PAYG_METER_RECONCILE_ENABLED:true} +payg.meter.reconcile.retry-delay=${PAYG_METER_RECONCILE_RETRY_DELAY:PT5M} +payg.meter.reconcile.batch-size=${PAYG_METER_RECONCILE_BATCH_SIZE:100} +payg.meter.reconcile-cron=${PAYG_METER_RECONCILE_CRON:0 */15 * * * *} + +# ---------- PAYG customer portal ---------- +# The Stripe billing-portal session is minted FE-direct: the Plan page calls the Supabase +# `create-customer-portal-session` edge function with the user's JWT (same pattern as checkout), +# and the function's SECURITY DEFINER RPC enforces team membership. No Java proxy, no backend +# config — return_url host allowlisting lives in the edge fn (PORTAL_ALLOWED_RETURN_HOSTS). + supabase.url=https://${app.supabase.project-ref}.supabase.co spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql new file mode 100644 index 000000000..f78458a1b --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql @@ -0,0 +1,58 @@ +-- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that +-- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter +-- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing. +-- +-- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates +-- it for new rows going forward. + +-- --------------------------------------------------------------------------------------------- +-- 1. wallet_ledger.billing_category +-- --------------------------------------------------------------------------------------------- +ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL; +COMMENT ON COLUMN wallet_ledger.billing_category IS + 'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.'; + +-- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree. +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period + ON wallet_ledger (team_id, billing_category, occurred_at) + WHERE billing_category IS NOT NULL; + +-- --------------------------------------------------------------------------------------------- +-- 2. payg_shadow_charge.billing_category + job_source +-- --------------------------------------------------------------------------------------------- +ALTER TABLE payg_shadow_charge + ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL, + ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL; + +-- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned +-- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack +-- the column.) +UPDATE payg_shadow_charge sc + SET job_source = pj.source + FROM processing_job pj + WHERE pj.job_id = sc.job_id + AND sc.job_source IS NULL; + +-- --------------------------------------------------------------------------------------------- +-- 3. pricing_policy_stripe_price.stripe_product_id +-- Operator populates this manually per row when seeding new policies. Nullable for backward +-- compatibility with existing rows that don't carry a Product reference. +-- --------------------------------------------------------------------------------------------- +ALTER TABLE pricing_policy_stripe_price + ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL; + +-- --------------------------------------------------------------------------------------------- +-- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that +-- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the +-- partial index above. +-- --------------------------------------------------------------------------------------------- +CREATE OR REPLACE VIEW wallet_category_summary AS +SELECT + team_id, + date_trunc('month', occurred_at) AS period_start, + billing_category, + SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited, + COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count +FROM wallet_ledger +WHERE billing_category IS NOT NULL +GROUP BY team_id, date_trunc('month', occurred_at), billing_category; diff --git a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql new file mode 100644 index 000000000..cc6b5d033 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql @@ -0,0 +1,34 @@ +-- Consolidate users.supabase_id into users.supabase_auth_id. +-- +-- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was +-- created by the initial Supabase schema migration (Sep 2025) and is referenced +-- by every RLS policy in the Supabase side of the world (V14's +-- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_* +-- SECURITY DEFINER RPCs, etc.). +-- +-- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id` +-- column via Flyway V2 — same purpose, different name. Java's User entity then +-- mapped to this new column. The result was a split-brain: +-- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL. +-- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL. +-- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users +-- failed every membership check. +-- +-- This migration: +-- 1. Backfills supabase_auth_id from supabase_id where the former is NULL. +-- 2. Drops the supabase_id column and its unique index. +-- +-- The Java User entity has been switched to @Column(name = "supabase_auth_id") +-- in the same change-set; this migration assumes the new code is already +-- deployed (or will be deployed together with this migration). + +-- 1. Backfill the canonical column from the duplicate, where needed. +UPDATE users + SET supabase_auth_id = supabase_id + WHERE supabase_auth_id IS NULL + AND supabase_id IS NOT NULL; + +-- 2. Drop the duplicate column. IF EXISTS guards against environments where +-- the column was already removed manually. +DROP INDEX IF EXISTS uk_users_supabase_id; +ALTER TABLE users DROP COLUMN IF EXISTS supabase_id; diff --git a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql new file mode 100644 index 000000000..c2743f0e9 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql @@ -0,0 +1,95 @@ +-- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant. +-- +-- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT +-- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused. +-- +-- Mechanics: the grant is tracked as a running counter on the team sidecar +-- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing +-- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written, +-- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is +-- no longer the source of truth for the grant and its old rows can be pruned after a retention +-- window (separate future job). +-- +-- See notes/payg-lifetime-free-grant-plan-2026-06-11.html. + +-- --------------------------------------------------------------------------------------------- +-- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size. +-- --------------------------------------------------------------------------------------------- + +ALTER TABLE stirling_pdf.pricing_policy + RENAME COLUMN free_tier_units_per_cycle TO free_tier_units; + +COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS + 'One-time lifetime free document grant handed to a team on creation (copied into ' + 'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and ' + 'survives subscribing. 0 = no free grant (block / meter from the first document).'; + +-- --------------------------------------------------------------------------------------------- +-- 2. The running counter on the team sidecar. +-- --------------------------------------------------------------------------------------------- + +ALTER TABLE stirling_pdf.payg_team_extensions + ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS + 'Remaining one-time free documents for this team. Seeded from the effective pricing ' + 'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a ' + 'billable charge is written; restored on a first-step refund. Lifetime — never resets. ' + 'Authoritative source for the free grant (independent of wallet_ledger retention).'; + +-- --------------------------------------------------------------------------------------------- +-- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes +-- any need to SUM the ledger over a team's lifetime. +-- --------------------------------------------------------------------------------------------- + +ALTER TABLE stirling_pdf.payg_shadow_charge + ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS + 'How many of this job''s payg_units came out of the team''s free grant at charge time. ' + 'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many ' + 'units to payg_team_extensions.free_units_remaining.'; + +-- --------------------------------------------------------------------------------------------- +-- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the +-- default policy's grant from minute one. (The trigger itself still points at this function.) +-- --------------------------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining) + VALUES ( + NEW.team_id, + COALESCE( + (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp + WHERE pp.is_default = TRUE LIMIT 1), + 0) + ) + ON CONFLICT (team_id) DO NOTHING; + RETURN NEW; +END $$; + +-- --------------------------------------------------------------------------------------------- +-- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed +-- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds +-- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the +-- ledger; after this the counter stands alone. Grant = team override policy, else the default. +-- --------------------------------------------------------------------------------------------- + +UPDATE stirling_pdf.payg_team_extensions ext + SET free_units_remaining = GREATEST( + 0, + COALESCE( + (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp + WHERE pp.policy_id = ext.pricing_policy_id), + (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp + WHERE pp.is_default = TRUE LIMIT 1), + 0) + + COALESCE( + (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl + WHERE wl.team_id = ext.team_id + AND wl.entry_type IN ('DEBIT', 'REFUND')), + 0)); diff --git a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql new file mode 100644 index 000000000..d9dc756e3 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql @@ -0,0 +1,42 @@ +-- PAYG launch free grant: give the default pricing policy a real one-time grant. +-- +-- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12 +-- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and +-- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from +-- the very first billable document). This migration sets the launch grant on the default policy +-- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still +-- 0, so every then-existing team computed to 0). +-- +-- The launch value lives on the default policy row; tune it there (or via a future admin surface). +-- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never +-- clobbered. + +-- --------------------------------------------------------------------------------------------- +-- 1. Launch grant on the default policy, only where it's still the accidental 0. +-- --------------------------------------------------------------------------------------------- +UPDATE stirling_pdf.pricing_policy + SET free_tier_units = 500 + WHERE is_default = TRUE + AND free_tier_units = 0; + +-- --------------------------------------------------------------------------------------------- +-- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining = +-- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to +-- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and +-- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe. +-- --------------------------------------------------------------------------------------------- +UPDATE stirling_pdf.payg_team_extensions ext + SET free_units_remaining = GREATEST( + 0, + COALESCE( + (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp + WHERE pp.policy_id = ext.pricing_policy_id), + (SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp + WHERE pp.is_default = TRUE LIMIT 1), + 0) + + COALESCE( + (SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl + WHERE wl.team_id = ext.team_id + AND wl.entry_type IN ('DEBIT', 'REFUND')), + 0)) + WHERE ext.free_units_remaining = 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql new file mode 100644 index 000000000..857e0bfa6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql @@ -0,0 +1,11 @@ +-- Drop the unused wallet_category_summary view. +-- +-- V16 created this view to back the wallet's per-category spend breakdown via +-- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA +-- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this +-- view have zero readers. The DAO is deleted in the same change; this drops the dead view. +-- +-- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on +-- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run. + +DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java new file mode 100644 index 000000000..8433f5e5a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -0,0 +1,489 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest; +import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.entitlement.EntitlementService; +import stirling.software.saas.payg.entitlement.EntitlementSnapshot; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.model.LedgerEntryType; +import stirling.software.saas.payg.repository.PaygShadowChargeRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.WalletLedgerRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.wallet.WalletPolicy; +import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.saas.security.EnhancedJwtAuthenticationToken; + +/** + * Pure-Mockito unit tests for {@link PaygWalletController}. Covers the documented role / state + * matrix — free vs subscribed, leader vs member, anonymous — plus the cap update endpoint's + * leader-only enforcement and cache invalidation. + */ +@ExtendWith(MockitoExtension.class) +class PaygWalletControllerTest { + + @Mock private EntitlementService entitlementService; + @Mock private TeamBillingService billingService; + @Mock private TeamMembershipRepository memberRepo; + @Mock private PaygTeamExtensionsRepository extRepo; + @Mock private WalletPolicyRepository policyRepo; + @Mock private WalletLedgerRepository ledgerRepo; + @Mock private PaygShadowChargeRepository shadowRepo; + @Mock private UserRepository userRepository; + + private PaygWalletController controller; + + @BeforeEach + void setUp() { + controller = + new PaygWalletController( + entitlementService, + billingService, + memberRepo, + extRepo, + policyRepo, + ledgerRepo, + shadowRepo, + userRepository); + } + + /** + * Free-team billing context: the one-time grant is fully unused (remaining == grant), no + * subscription facts, no monthly cap. The displayed limit comes from the snapshot, not here. + */ + private static TeamBillingContext freeBilling(long teamFreeGrant) { + LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + return new TeamBillingContext( + false, + null, + start, + start.plusMonths(1), + teamFreeGrant, + teamFreeGrant, + null, + null, + null, + null); + } + + /** + * Subscribed billing context with a money cap (minor units) and a known per-doc rate. The grant + * is treated as exhausted (remaining 0) — typical for a team that has subscribed; {@code + * monthlyCapDocUnits} is the paid-doc ceiling {@code floor(capMoney / rate)}. + */ + private static TeamBillingContext subscribedBilling( + String subscriptionId, Long capMoneyMinor, Long monthlyCapDocUnits) { + LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + return new TeamBillingContext( + true, + subscriptionId, + start, + start.plusMonths(1), + 500L, + 0L, + BigDecimal.valueOf(2), + "usd", + capMoneyMinor, + monthlyCapDocUnits); + } + + private void stubEmptyLedgerReads(long teamId) { + when(ledgerRepo.sumPeriodAmountByCategory( + eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn(List.of()); + when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of()); + } + + // ----------------------------------------------------------------------------------------- + // GET /wallet + // ----------------------------------------------------------------------------------------- + + @Test + void getWallet_freeTier_returnsFreeShape() { + User user = userWithId(7L, UUID.randomUUID()); + Team team = teamWithId(42L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(7L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + when(billingService.forTeam(42L)).thenReturn(freeBilling(500L)); + when(entitlementService.getSnapshot(42L)).thenReturn(snapshot(0L, 500L)); + stubEmptyLedgerReads(42L); + + ResponseEntity resp = + controller.getWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + WalletSnapshotResponse body = resp.getBody(); + assertThat(body).isNotNull(); + assertThat(body.status()).isEqualTo("free"); + assertThat(body.role()).isEqualTo("member"); + assertThat(body.capUsd()).isNull(); + assertThat(body.stripeSubscriptionId()).isNull(); + assertThat(body.noCap()).isFalse(); + assertThat(body.billableUsed()).isZero(); + assertThat(body.billableLimit()).isEqualTo(500); + assertThat(body.freeAllowance()).isEqualTo(500); + assertThat(body.pricePerDocMinor()).isNull(); + assertThat(body.currency()).isNull(); + assertThat(body.estimatedBillMinor()).isNull(); + assertThat(body.members()).isEmpty(); + assertThat(body.recent()).isEmpty(); + assertThat(body.categoryBreakdown().api()).isZero(); + assertThat(body.categoryBreakdown().ai()).isZero(); + assertThat(body.categoryBreakdown().automation()).isZero(); + } + + @Test + void getWallet_subscribedMember_returnsCapAndBreakdownFromLedger() { + User user = userWithId(8L, UUID.randomUUID()); + Team team = teamWithId(99L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(8L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + // $25 cap (2500 minor) at $0.02/doc → 1250 paid docs/month (the one-time grant is a + // separate pool, not added to the cap). + when(billingService.forTeam(99L)) + .thenReturn(subscribedBilling("sub_test_99", 2500L, 1250L)); + // 312 paid (metered) docs this period → estimate 312 × $0.02 = $6.24 (624 minor). + when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L); + when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L)); + when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L)); + when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn( + List.of( + new Object[] {BillingCategory.API, 110L}, + new Object[] {BillingCategory.AI, 200L}, + new Object[] {BillingCategory.AUTOMATION, 2L})); + when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of()); + + ResponseEntity resp = + controller.getWallet(jwtAuth(user.getSupabaseId())); + + WalletSnapshotResponse body = resp.getBody(); + assertThat(body.status()).isEqualTo("subscribed"); + assertThat(body.role()).isEqualTo("member"); + assertThat(body.capUsd()).isEqualTo(25); + assertThat(body.noCap()).isFalse(); + assertThat(body.billableUsed()).isEqualTo(312); + assertThat(body.spendUnitsThisPeriod()).isEqualTo(312); + assertThat(body.billableLimit()).isEqualTo(1250); + assertThat(body.freeAllowance()).isEqualTo(500); + assertThat(body.pricePerDocMinor()).isEqualByComparingTo(BigDecimal.valueOf(2)); + assertThat(body.currency()).isEqualTo("usd"); + assertThat(body.estimatedBillMinor()).isEqualTo(624L); + assertThat(body.members()).isEmpty(); + assertThat(body.categoryBreakdown().api()).isEqualTo(110); + assertThat(body.categoryBreakdown().ai()).isEqualTo(200); + assertThat(body.categoryBreakdown().automation()).isEqualTo(2); + assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99"); + // Member role → ledger never queried per-user. + verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any()); + } + + @Test + void getWallet_subscribedNoCap_returnsNoCapTrueAndNullLimit() { + User user = userWithId(9L, UUID.randomUUID()); + Team team = teamWithId(11L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(9L)) + .thenReturn(List.of(membership(team, user, TeamRole.LEADER))); + when(billingService.forTeam(11L)).thenReturn(subscribedBilling("sub_nocap", null, null)); + when(entitlementService.getSnapshot(11L)).thenReturn(snapshot(50L, null)); + stubEmptyLedgerReads(11L); + when(memberRepo.findByTeamId(11L)).thenReturn(List.of()); + + ResponseEntity resp = + controller.getWallet(jwtAuth(user.getSupabaseId())); + + WalletSnapshotResponse body = resp.getBody(); + assertThat(body.status()).isEqualTo("subscribed"); + assertThat(body.role()).isEqualTo("leader"); + assertThat(body.capUsd()).isNull(); + assertThat(body.noCap()).isTrue(); + // Uncapped → no document ceiling to draw a bar against. + assertThat(body.billableLimit()).isNull(); + } + + @Test + void getWallet_leader_populatesMembers() { + User leader = userWithId(10L, UUID.randomUUID()); + User member = userWithId(11L, UUID.randomUUID()); + member.setUsername("alice"); + member.setEmail("alice@example.com"); + Team team = teamWithId(77L); + TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER); + TeamMembership memberRow = membership(team, member, TeamRole.MEMBER); + + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(10L)).thenReturn(List.of(leaderRow)); + when(billingService.forTeam(77L)).thenReturn(freeBilling(500L)); + when(entitlementService.getSnapshot(77L)).thenReturn(snapshot(0L, 500L)); + stubEmptyLedgerReads(77L); + when(memberRepo.findByTeamId(77L)).thenReturn(List.of(leaderRow, memberRow)); + // Ledger returns signed (negative) debits. + when(ledgerRepo.sumPeriodAmountForMember(eq(77L), any(), any(), any(), any())) + .thenReturn(-42L); + + ResponseEntity resp = + controller.getWallet(jwtAuth(leader.getSupabaseId())); + + WalletSnapshotResponse body = resp.getBody(); + assertThat(body.role()).isEqualTo("leader"); + assertThat(body.members()).hasSize(2); + MemberRow secondMember = + body.members().stream() + .filter(m -> "alice".equals(m.name())) + .findFirst() + .orElseThrow(); + assertThat(secondMember.email()).isEqualTo("alice@example.com"); + assertThat(secondMember.spendUnits()).isEqualTo(42); + } + + @Test + void getWallet_anonymousIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.getWallet(anon); + + // AuthenticationUtils.getCurrentUser throws SecurityException for "anonymousUser" since it + // has no Supabase id and is not a User principal — the controller maps that to 401. + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService, billingService, memberRepo, extRepo, policyRepo); + } + + @Test + void getWallet_authenticatedNoTeam_returnsEmptyFreeShape() { + User user = userWithId(12L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(12L)).thenReturn(List.of()); + + ResponseEntity resp = + controller.getWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody().status()).isEqualTo("free"); + assertThat(resp.getBody().members()).isEmpty(); + // Entitlement service must not be queried for a teamless user (avoids null-key NPE). + verifyNoInteractions(entitlementService); + } + + // ----------------------------------------------------------------------------------------- + // PATCH /cap + // ----------------------------------------------------------------------------------------- + + @Test + void updateCap_leaderUpdatesUnitsAndInvalidates() { + User leader = userWithId(20L, UUID.randomUUID()); + Team team = teamWithId(33L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(20L)) + .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); + when(policyRepo.findByTeamId(33L)).thenReturn(Optional.empty()); + + ResponseEntity resp = + controller.updateCap( + new UpdateCapRequest(40, false), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); + verify(policyRepo).save(saved.capture()); + // Rate unknown in this test (docCapForMoney → empty) → legacy conversion fallback so + // the cap stays enforced rather than silently lifting. + assertThat(saved.getValue().getCapUnits()).isEqualTo(4000L); // 40 USD * 100 units/USD + assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(4000L); // 40 USD == 4000 cents + verify(entitlementService, times(1)).invalidate(33L); + } + + @Test + void updateCap_withKnownRate_storesDerivedDocCap() { + User leader = userWithId(24L, UUID.randomUUID()); + Team team = teamWithId(36L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(24L)) + .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); + when(policyRepo.findByTeamId(36L)).thenReturn(Optional.empty()); + TeamBillingContext billing = subscribedBilling("sub_36", null, null); + when(billingService.forTeam(36L)).thenReturn(billing); + // $28 cap at $0.02/doc → 1400 paid documents/month (the grant is a separate pool). + when(billingService.docCapForMoney(billing, 2800L)).thenReturn(Optional.of(1400L)); + + ResponseEntity resp = + controller.updateCap( + new UpdateCapRequest(28, false), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); + verify(policyRepo).save(saved.capture()); + assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(2800L); + assertThat(saved.getValue().getCapUnits()).isEqualTo(1400L); + verify(entitlementService).invalidate(36L); + } + + @Test + void updateCap_noCapTrue_clearsCapUnits() { + User leader = userWithId(21L, UUID.randomUUID()); + Team team = teamWithId(34L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader)); + when(memberRepo.findPrimaryMembership(21L)) + .thenReturn(List.of(membership(team, leader, TeamRole.LEADER))); + WalletPolicy existing = new WalletPolicy(); + existing.setTeamId(34L); + existing.setCapUnits(1000L); + existing.setCapSourceMoney(1000L); + when(policyRepo.findByTeamId(34L)).thenReturn(Optional.of(existing)); + + ResponseEntity resp = + controller.updateCap( + new UpdateCapRequest(0, true), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor saved = ArgumentCaptor.forClass(WalletPolicy.class); + verify(policyRepo).save(saved.capture()); + assertThat(saved.getValue().getCapUnits()).isNull(); + assertThat(saved.getValue().getCapSourceMoney()).isNull(); + verify(entitlementService).invalidate(34L); + } + + @Test + void updateCap_memberIsForbidden() { + User member = userWithId(22L, UUID.randomUUID()); + Team team = teamWithId(35L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member)); + when(memberRepo.findPrimaryMembership(22L)) + .thenReturn(List.of(membership(team, member, TeamRole.MEMBER))); + + ResponseEntity resp = + controller.updateCap( + new UpdateCapRequest(50, false), jwtAuth(member.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(policyRepo, never()).save(any()); + verify(entitlementService, never()).invalidate(any()); + } + + @Test + void updateCap_noTeam_isForbidden() { + User user = userWithId(23L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(23L)).thenReturn(List.of()); + + ResponseEntity resp = + controller.updateCap( + new UpdateCapRequest(10, false), jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(policyRepo, never()).save(any()); + } + + @Test + void updateCap_anonymousIs401() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.updateCap(new UpdateCapRequest(10, false), anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(policyRepo, entitlementService); + } + + // ----------------------------------------------------------------------------------------- + // Fixtures + // ----------------------------------------------------------------------------------------- + + private static User userWithId(Long id, UUID supabaseId) { + User u = new User(); + u.setId(id); + u.setSupabaseId(supabaseId); + return u; + } + + private static Team teamWithId(Long id) { + Team t = new Team(); + t.setId(id); + t.setName("t-" + id); + return t; + } + + private static TeamMembership membership(Team team, User user, TeamRole role) { + TeamMembership m = new TeamMembership(); + m.setTeam(team); + m.setUser(user); + m.setRole(role); + return m; + } + + private static EntitlementSnapshot snapshot(long spend, Long cap) { + LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay(); + LocalDateTime end = start.plusMonths(1); + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE), + spend, + cap, + start, + end, + false); + } + + private static Authentication jwtAuth(UUID supabaseId) { + Jwt jwt = + Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", supabaseId.toString()) + .claim("email", "user@example.com") + .build(); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(), "user@example.com", supabaseId.toString()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java new file mode 100644 index 000000000..2118f8579 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java @@ -0,0 +1,150 @@ +package stirling.software.saas.payg.cap; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.cap.CapEvaluator.Evaluation; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +class CapEvaluatorTest { + + // --------------------------------------------------------------------------------------- + // evaluate(): single-axis cap evaluation + // --------------------------------------------------------------------------------------- + + @Test + void nullCap_returnsFullStateAndFullGates() { + Evaluation e = CapEvaluator.evaluate(1_000_000L, null, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.FULL); + assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(e.enabledGates()) + .containsExactlyInAnyOrder( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE); + } + + @Test + void zeroCap_treatedAsUnlimitedForSafety() { + // Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL). + Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void wellBelowWarn_returnsFull() { + Evaluation e = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.FULL); + assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); + } + + @Test + void exactlyAtWarnThreshold_returnsWarned() { + // 80% of 100 = 80; pct = 80 / 100 * 100 = 80 → ≥ warnAtPct=80 → WARNED + Evaluation e = CapEvaluator.evaluate(80L, 100L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.WARNED); + // Warn band keeps the FULL feature set — only flags the FE to show a banner. + assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(e.enabledGates()).hasSize(4); + } + + @Test + void betweenWarnAndDegrade_returnsWarned() { + Evaluation e = CapEvaluator.evaluate(95L, 100L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.WARNED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL); + } + + @Test + void exactlyAtDegradeThreshold_returnsDegradedWithConfiguredSet() { + Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); + // MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; only + // AUTOMATION + AI_SUPPORT are blocked. + assertThat(e.enabledGates()) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + } + + @Test + void overDegradeThreshold_returnsDegradedAndCannotEscalate() { + Evaluation e = CapEvaluator.evaluate(500L, 100L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); + } + + @Test + void degradedFeatureSetClientOnly_dropsEverythingButClientSide() { + Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.CLIENT_ONLY); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.CLIENT_ONLY); + assertThat(e.enabledGates()).containsExactly(FeatureGate.CLIENT_SIDE); + } + + @Test + void nullDegradedFeatureSet_fallsBackToMinimal() { + Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, null); + assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL); + } + + @Test + void misconfiguredThresholds_treatedAsNoCap() { + // warn > degrade is malformed; the evaluator returns FULL rather than risk wrong + // degradation. The admin write path is supposed to validate this. + Evaluation e = CapEvaluator.evaluate(100L, 100L, 110, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.FULL); + + // negative warn → bad config → FULL + Evaluation neg = CapEvaluator.evaluate(100L, 100L, -10, 100, FeatureSet.MINIMAL); + assertThat(neg.state()).isEqualTo(EntitlementState.FULL); + + // zero degrade → bad config → FULL (would otherwise degrade on any spend) + Evaluation zero = CapEvaluator.evaluate(0L, 100L, 80, 0, FeatureSet.MINIMAL); + assertThat(zero.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void zeroSpend_returnsFullEvenIfCapTiny() { + Evaluation e = CapEvaluator.evaluate(0L, 1L, 80, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void warnAtZeroPct_warnsImmediately() { + // Edge case: warn-at-0% means any spend → WARNED. Allowed even if quirky. + Evaluation e = CapEvaluator.evaluate(1L, 100L, 0, 100, FeatureSet.MINIMAL); + assertThat(e.state()).isEqualTo(EntitlementState.WARNED); + } + + // --------------------------------------------------------------------------------------- + // gatesFor(): mapping FeatureSet → declared gates + // --------------------------------------------------------------------------------------- + + @Test + void gatesFor_full_listsAllFour() { + assertThat(CapEvaluator.gatesFor(FeatureSet.FULL)) + .containsExactlyInAnyOrder( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE); + } + + @Test + void gatesFor_minimal_keepsOffsiteAndClientSide() { + // MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; AUTOMATION + + // AI_SUPPORT are the only gates dropped on degrade. + assertThat(CapEvaluator.gatesFor(FeatureSet.MINIMAL)) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + } + + @Test + void gatesFor_null_returnsEmpty() { + assertThat(CapEvaluator.gatesFor(null)).isEmpty(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java new file mode 100644 index 000000000..c2d5fcd8b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java @@ -0,0 +1,52 @@ +package stirling.software.saas.payg.cap; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotationUtils; + +import stirling.software.saas.ai.controller.AiCreateController; +import stirling.software.saas.ai.controller.AiCreateInternalController; +import stirling.software.saas.ai.controller.AiProxyController; +import stirling.software.saas.payg.model.FeatureGate; + +/** + * Annotation-rollout guard. The saas {@code PaygChargeInterceptor} reads class-level + * {@code @RequiresFeature} via {@link AnnotationUtils#findAnnotation(Class, Class)} to decide + * whether a request bills as {@code AI}, {@code AUTOMATION}, or falls through to the auth-derived + * default. These tests pin the gate on each AI surface so the classification can't silently regress + * to {@code BYPASSED} if someone strips the annotation while refactoring. + * + *

Out of scope: {@code PipelineController} (in core) and {@code PolicyController} (in + * proprietary) — neither module can import {@code @RequiresFeature} from saas without a forbidden + * upward dependency. Their automation classification is enforced via the {@code + * X-Stirling-Automation} header set unconditionally by {@code InternalApiClient.post}; see the + * dedicated test in that module. + */ +class RequiresFeatureAnnotationRolloutTest { + + @Test + void aiCreateController_isClassifiedAsAiSupport() { + RequiresFeature ann = + AnnotationUtils.findAnnotation(AiCreateController.class, RequiresFeature.class); + assertThat(ann).isNotNull(); + assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); + } + + @Test + void aiCreateInternalController_isClassifiedAsAiSupport() { + RequiresFeature ann = + AnnotationUtils.findAnnotation( + AiCreateInternalController.class, RequiresFeature.class); + assertThat(ann).isNotNull(); + assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); + } + + @Test + void aiProxyController_isClassifiedAsAiSupport() { + RequiresFeature ann = + AnnotationUtils.findAnnotation(AiProxyController.class, RequiresFeature.class); + assertThat(ann).isNotNull(); + assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 29e7041bf..9a8c7848f 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -17,14 +17,18 @@ import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import stirling.software.saas.payg.docs.DocumentClassifier; @@ -33,15 +37,24 @@ import stirling.software.saas.payg.job.JobContext; import stirling.software.saas.payg.job.JobService; import stirling.software.saas.payg.job.JoinOrOpenResult; import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.meter.PaygMeterReportingService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.JobSource; import stirling.software.saas.payg.model.JobStatus; +import stirling.software.saas.payg.model.LedgerBucket; +import stirling.software.saas.payg.model.LedgerEntryType; import stirling.software.saas.payg.model.ProcessType; +import stirling.software.saas.payg.model.ReferenceType; import stirling.software.saas.payg.model.ShadowChargeStatus; +import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.policy.PricingPolicy; import stirling.software.saas.payg.policy.PricingPolicyService; import stirling.software.saas.payg.repository.PaygShadowChargeRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.ProcessingJobRepository; +import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.shadow.PaygShadowCharge; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; /** * Exercises {@link JobChargeService} as an orchestrator: policy lookup, step-limit resolution, @@ -54,6 +67,9 @@ class JobChargeServiceTest { private DocumentClassifier classifier; private PaygShadowChargeRepository shadowRepo; private ProcessingJobRepository jobRepo; + private PaygTeamExtensionsRepository teamExtRepo; + private PaygMeterReportingService meterReporter; + private WalletLedgerRepository ledgerRepo; private JobChargeService service; @BeforeEach @@ -63,7 +79,31 @@ class JobChargeServiceTest { classifier = Mockito.mock(DocumentClassifier.class); shadowRepo = Mockito.mock(PaygShadowChargeRepository.class); jobRepo = Mockito.mock(ProcessingJobRepository.class); - service = new JobChargeService(jobService, policyService, classifier, shadowRepo, jobRepo); + teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class); + meterReporter = Mockito.mock(PaygMeterReportingService.class); + ledgerRepo = Mockito.mock(WalletLedgerRepository.class); + // findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless + // a test stubs the sidecar row. The free split is decided at openProcess time now, not at + // close, so the meter tests just set free_units_consumed on the shadow row directly. + service = + new JobChargeService( + jobService, + policyService, + classifier, + shadowRepo, + jobRepo, + teamExtRepo, + meterReporter, + ledgerRepo); + } + + @AfterEach + void tearDown() { + // Defensive: a previous test could have left a fake synchronization registered. Clearing + // ensures isolation when tests run in any order. + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clear(); + } } @Test @@ -80,7 +120,12 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), + new ChargeContext( + 42L, + 100L, + JobSource.WEB, + ProcessType.SINGLE_TOOL, + BillingCategory.API), List.of(in)); assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.JOINED); @@ -89,6 +134,7 @@ class JobChargeServiceTest { verify(classifier, never()).classify(any(MultipartFile.class), any()); verify(classifier, never()).classify(anyList(), any()); verify(shadowRepo, never()).save(any()); + verify(ledgerRepo, never()).save(any()); } @Test @@ -106,7 +152,12 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), + new ChargeContext( + 42L, + 100L, + JobSource.WEB, + ProcessType.SINGLE_TOOL, + BillingCategory.API), List.of(in)); assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.OPENED); @@ -126,9 +177,82 @@ class JobChargeServiceTest { // Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up. assertThat(row.getLegacyCreditsCharged()).isZero(); assertThat(row.getDiffPct()).isZero(); + // PAYG analytics axis: billing_category + job_source are copied from the context so the + // row stays self-describing after processing_job rows are pruned. + assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.API); + assertThat(row.getJobSource()).isEqualTo(JobSource.WEB); // Job entity carries the classified docUnits so close-time receipts can render correctly. assertThat(newJob.getDocUnits()).isEqualTo(4); + + // Live ledger DEBIT mirrors the shadow row: same units, stored NEGATIVE per the + // wallet_ledger sign convention, tied back to the job via reference. + ArgumentCaptor ledgerCaptor = + ArgumentCaptor.forClass(WalletLedgerEntry.class); + verify(ledgerRepo).save(ledgerCaptor.capture()); + WalletLedgerEntry debit = ledgerCaptor.getValue(); + assertThat(debit.getTeamId()).isEqualTo(100L); + assertThat(debit.getActorUserId()).isEqualTo(42L); + assertThat(debit.getEntryType()).isEqualTo(LedgerEntryType.DEBIT); + assertThat(debit.getBucket()).isEqualTo(LedgerBucket.CYCLE); + assertThat(debit.getAmountUnits()).isEqualTo(-4); + assertThat(debit.getReferenceType()).isEqualTo(ReferenceType.JOB); + assertThat(debit.getReferenceId()).isEqualTo(newJob.getId().toString()); + assertThat(debit.getPolicyId()).isEqualTo(policy.getId()); + assertThat(debit.getBillingCategory()).isEqualTo(BillingCategory.API); + } + + @Test + void openProcess_bypassedCategory_writesShadowRowButNoLedgerDebit(@TempDir Path tmp) + throws IOException { + // Manual UI work is never billed: the shadow row still lands (comparison audit trail) + // but the live wallet_ledger must stay untouched. + PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); + + service.openProcess( + new ChargeContext( + 42L, + 100L, + JobSource.WEB, + ProcessType.SINGLE_TOOL, + BillingCategory.BYPASSED), + List.of(jobInput(tmp, "in.pdf", "application/pdf"))); + + verify(shadowRepo).save(any(PaygShadowCharge.class)); + verify(ledgerRepo, never()).save(any()); + } + + @Test + void openProcess_openedAutomationContext_writesShadowRowWithAutomationCategory( + @TempDir Path tmp) throws IOException { + PricingPolicy policy = + stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10, JobSource.PIPELINE, 20)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); + + service.openProcess( + new ChargeContext( + 42L, + 100L, + JobSource.PIPELINE, + ProcessType.AUTOMATION, + BillingCategory.AUTOMATION), + List.of(jobInput(tmp, "in.pdf", "application/pdf"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); + verify(shadowRepo).save(captor.capture()); + assertThat(captor.getValue().getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION); + assertThat(captor.getValue().getJobSource()).isEqualTo(JobSource.PIPELINE); } @Test @@ -146,7 +270,12 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.AUTOMATION), + new ChargeContext( + 42L, + 100L, + JobSource.WEB, + ProcessType.AUTOMATION, + BillingCategory.AUTOMATION), List.of(a, b)); assertThat(out.units()).isEqualTo(7); @@ -167,12 +296,107 @@ class JobChargeServiceTest { ChargeOutcome out = service.openProcess( - new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), + new ChargeContext( + 42L, + 100L, + JobSource.WEB, + ProcessType.SINGLE_TOOL, + BillingCategory.API), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); assertThat(out.units()).isEqualTo(5); } + @Test + void openProcess_drawsFreeGrant_storesSplitAndDecrementsCounter(@TempDir Path tmp) + throws IOException { + // Team has 10 free units left; a 4-unit job draws all 4 from the grant. The shadow row + // records free_units_consumed = 4 (so nothing meters) and the counter drops to 6. + PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setFreeUnitsRemaining(10L); + when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); + + service.openProcess( + new ChargeContext( + 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), + List.of(jobInput(tmp, "in.pdf", "application/pdf"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); + verify(shadowRepo).save(captor.capture()); + assertThat(captor.getValue().getPaygUnits()).isEqualTo(4); + assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4); + // Counter decremented in-place and persisted. + assertThat(ext.getFreeUnitsRemaining()).isEqualTo(6L); + verify(teamExtRepo).save(ext); + } + + @Test + void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp) + throws IOException { + // Only 3 free units left; a 10-unit job takes the 3 (counter → 0) and the other 7 bill. + PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 10)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setFreeUnitsRemaining(3L); + when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); + + service.openProcess( + new ChargeContext( + 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), + List.of(jobInput(tmp, "in.pdf", "application/pdf"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); + verify(shadowRepo).save(captor.capture()); + assertThat(captor.getValue().getPaygUnits()).isEqualTo(10); + assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(3); + assertThat(ext.getFreeUnitsRemaining()).isZero(); + verify(teamExtRepo).save(ext); + } + + @Test + void openProcess_exhaustedGrant_storesZeroFreeAndLeavesCounterUntouched(@TempDir Path tmp) + throws IOException { + // Grant already at 0 → nothing free, full units bill, counter not re-saved. + PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(100L)).thenReturn(policy); + ProcessingJob newJob = openJob(UUID.randomUUID()); + when(jobService.joinOrOpen(any(JobContext.class), anyList())) + .thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED)); + when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy))) + .thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 5)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setFreeUnitsRemaining(0L); + when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext)); + + service.openProcess( + new ChargeContext( + 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API), + List.of(jobInput(tmp, "in.pdf", "application/pdf"))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PaygShadowCharge.class); + verify(shadowRepo).save(captor.capture()); + assertThat(captor.getValue().getFreeUnitsConsumed()).isZero(); + verify(teamExtRepo, never()).save(any()); + } + @Test void openProcess_resolvesStepLimitFromPolicy_perJobSource(@TempDir Path tmp) throws IOException { @@ -187,7 +411,12 @@ class JobChargeServiceTest { .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); service.openProcess( - new ChargeContext(42L, 100L, JobSource.PIPELINE, ProcessType.AUTOMATION), + new ChargeContext( + 42L, + 100L, + JobSource.PIPELINE, + ProcessType.AUTOMATION, + BillingCategory.AUTOMATION), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(JobContext.class); @@ -211,7 +440,12 @@ class JobChargeServiceTest { .thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1)); service.openProcess( - new ChargeContext(42L, 100L, JobSource.DESKTOP_APP, ProcessType.SINGLE_TOOL), + new ChargeContext( + 42L, + 100L, + JobSource.DESKTOP_APP, + ProcessType.SINGLE_TOOL, + BillingCategory.API), List.of(jobInput(tmp, "in.pdf", "application/pdf"))); ArgumentCaptor ctxCaptor = ArgumentCaptor.forClass(JobContext.class); @@ -225,7 +459,11 @@ class JobChargeServiceTest { () -> service.openProcess( new ChargeContext( - 42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL), + 42L, + 100L, + JobSource.WEB, + ProcessType.SINGLE_TOOL, + BillingCategory.API), List.of())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("inputs must not be empty"); @@ -234,9 +472,8 @@ class JobChargeServiceTest { @Test void markFirstStepFailed_flipsShadowRowAndClosesProcess() { UUID jobId = UUID.randomUUID(); - PaygShadowCharge row = new PaygShadowCharge(); - row.setJobId(jobId); - row.setStatus(ShadowChargeStatus.CHARGED); + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); + row.setPolicyId(7L); when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(java.util.Optional.of(row)); ProcessingJob job = openJob(jobId); when(jobRepo.findById(jobId)).thenReturn(java.util.Optional.of(job)); @@ -250,6 +487,37 @@ class JobChargeServiceTest { assertThat(job.getClosedAt()).isNotNull(); verify(shadowRepo).save(row); verify(jobRepo).save(job); + + // Compensating REFUND entry: positive amount mirroring the openProcess debit, same JOB + // reference so the pair nets to zero for the period. + ArgumentCaptor ledgerCaptor = + ArgumentCaptor.forClass(WalletLedgerEntry.class); + verify(ledgerRepo).save(ledgerCaptor.capture()); + WalletLedgerEntry refund = ledgerCaptor.getValue(); + assertThat(refund.getTeamId()).isEqualTo(100L); + assertThat(refund.getEntryType()).isEqualTo(LedgerEntryType.REFUND); + assertThat(refund.getBucket()).isEqualTo(LedgerBucket.CYCLE); + assertThat(refund.getAmountUnits()).isEqualTo(4); + assertThat(refund.getReferenceType()).isEqualTo(ReferenceType.JOB); + assertThat(refund.getReferenceId()).isEqualTo(jobId.toString()); + assertThat(refund.getPolicyId()).isEqualTo(7L); + assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API); + // This row consumed no free units, so the grant counter is left alone. + verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong()); + } + + @Test + void markFirstStepFailed_withFreeConsumed_restoresGrantToCounter() { + // A first-step failure is pre-meter: nothing billed to Stripe, but the grant moved at + // charge time. The refund must hand exactly those free units back to the team's counter. + UUID jobId = UUID.randomUUID(); + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId))); + + service.markFirstStepFailed(jobId, "first-step-5xx:503"); + + verify(teamExtRepo).restoreFreeUnits(100L, 3L); } @Test @@ -267,6 +535,8 @@ class JobChargeServiceTest { verify(shadowRepo, never()).save(any()); verify(jobRepo, never()).save(any()); + // No double-credit: the REFUND ledger entry only accompanies the CHARGED→REFUNDED flip. + verify(ledgerRepo, never()).save(any()); } @Test @@ -330,6 +600,262 @@ class JobChargeServiceTest { verify(jobRepo, never()).save(any()); } + // --- close() — meter reporting in afterCommit ----------------------------------------------- + + @Test + void close_subscribedTeam_postsMeterEventAfterCommit() { + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setStripeCustomerId("cus_subscribed"); + ext.setPaygSubscriptionId("sub_test"); + when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); + // Row consumed no free units (free_units_consumed = 0) → all 4 are paid and meter. + + withTransactionSynchronization( + () -> { + service.close(jobId); + Mockito.verifyNoInteractions(meterReporter); + }); + + // afterCommit ran on tearDown of withTransactionSynchronization → meter posted now. + verify(meterReporter) + .recordUsage( + 100L, + "cus_subscribed", + 4, + BillingCategory.API, + "process:" + jobId + ":close", + jobId); + } + + @Test + void close_fullyFreeJob_doesNotPostMeterEvent() { + // The free-vs-paid split is fixed at charge time. A job whose 4 units all came from the + // one-time grant (free_units_consumed = 4) has nothing left to meter; the ledger DEBIT + // alone records the usage. + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, 4, BillingCategory.API); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setStripeCustomerId("cus_subscribed"); + ext.setPaygSubscriptionId("sub_test"); + when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + } + + @Test + void close_partiallyFreeJob_metersOnlyThePaidPortion() { + // 20-unit job that drew 10 from the remaining grant at charge time (free_units_consumed = + // 10) → 10 paid units meter to Stripe. + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 20, 10, BillingCategory.AUTOMATION); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setStripeCustomerId("cus_subscribed"); + ext.setPaygSubscriptionId("sub_test"); + when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); + + withTransactionSynchronization(() -> service.close(jobId)); + + verify(meterReporter) + .recordUsage( + 100L, + "cus_subscribed", + 10, + BillingCategory.AUTOMATION, + "process:" + jobId + ":close", + jobId); + } + + @Test + void close_freeTierTeam_doesNotPostMeterEvent() { + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + // No stripe_customer_id → treated as free-tier on this branch (pre-#6532). + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setStripeCustomerId(null); + when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + } + + @Test + void close_noTeamExtensionsRow_doesNotPostMeterEvent() { + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + when(teamExtRepo.findById(100L)).thenReturn(Optional.empty()); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + } + + @Test + void close_refundedShadowRow_doesNotPostMeterEvent() { + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API); + row.setStatus(ShadowChargeStatus.REFUNDED); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + Mockito.verifyNoInteractions(teamExtRepo); + } + + @Test + void close_noShadowRow_doesNotPostMeterEvent() { + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.empty()); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + Mockito.verifyNoInteractions(teamExtRepo); + } + + @Test + void close_bypassedCategoryOnShadowRow_doesNotPostMeterEvent() { + // Defensive: BYPASSED rows shouldn't normally exist (the interceptor short-circuits + // before openProcess), but if one slips through we must not meter it. + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.BYPASSED); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + withTransactionSynchronization(() -> service.close(jobId)); + + Mockito.verifyNoInteractions(meterReporter); + Mockito.verifyNoInteractions(teamExtRepo); + } + + @Test + void close_meterReporterThrowsRuntimeException_doesNotPropagate() { + // PaygMeterReportingService is documented to swallow; defence-in-depth in + // JobChargeService catches a misbehaving impl so the afterCommit hook can't poison the + // close flow. + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.AUTOMATION); + when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row)); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(100L); + ext.setStripeCustomerId("cus_subscribed"); + ext.setPaygSubscriptionId("sub_test"); + when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext)); + + Mockito.doThrow(new RuntimeException("simulated meter failure")) + .when(meterReporter) + .recordUsage( + Mockito.anyLong(), + Mockito.anyString(), + Mockito.anyInt(), + Mockito.any(BillingCategory.class), + Mockito.anyString(), + Mockito.any(UUID.class)); + + // Should not throw — afterCommit's defence-in-depth wraps the call. + withTransactionSynchronization(() -> service.close(jobId)); + verify(meterReporter) + .recordUsage( + 100L, + "cus_subscribed", + 4, + BillingCategory.AUTOMATION, + "process:" + jobId + ":close", + jobId); + } + + @Test + void close_noActiveTransactionSync_skipsMeterPostButStillClosesJob() { + // Direct call without an outer @Transactional → no sync to register against. close() + // must still close the job; the meter post is implicitly deferred to whatever async path + // eventually wraps the call (or is never made, which is fine for ledger-only flows). + UUID jobId = UUID.randomUUID(); + ProcessingJob job = openJob(jobId); + when(jobService.close(jobId)).thenReturn(job); + + assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse(); + service.close(jobId); + + Mockito.verifyNoInteractions(meterReporter); + verify(jobService).close(jobId); + } + + private static void withTransactionSynchronization(Runnable body) { + TransactionSynchronizationManager.initSynchronization(); + try { + body.run(); + // Drain registered synchronizations to simulate a successful commit. + for (TransactionSynchronization sync : + TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + } finally { + TransactionSynchronizationManager.clear(); + } + } + + private static PaygShadowCharge chargedShadowRow( + UUID jobId, Long teamId, int units, BillingCategory category) { + return chargedShadowRow(jobId, teamId, units, 0, category); + } + + private static PaygShadowCharge chargedShadowRow( + UUID jobId, Long teamId, int units, int freeUnitsConsumed, BillingCategory category) { + PaygShadowCharge row = new PaygShadowCharge(); + row.setJobId(jobId); + row.setTeamId(teamId); + row.setPaygUnits(units); + row.setFreeUnitsConsumed(freeUnitsConsumed); + row.setStatus(ShadowChargeStatus.CHARGED); + row.setBillingCategory(category); + return row; + } + // --- helpers -------------------------------------------------------------------------------- private static PricingPolicy stubPolicy(int minCharge, Map stepLimits) { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java new file mode 100644 index 000000000..395ad95b9 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -0,0 +1,568 @@ +package stirling.software.saas.payg.entitlement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.method.HandlerMethod; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.payg.cap.RequiresFeature; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.security.EnhancedJwtAuthenticationToken; + +/** + * Pure-Mockito tests for {@link EntitlementGuard}. Covers the four decision-matrix cells: anonymous + * billable → 401, anonymous manual → pass, authenticated FULL → pass, authenticated DEGRADED for a + * billable route → 402. + */ +class EntitlementGuardTest { + + private EntitlementService entitlementService; + private UserRepository userRepository; + private MeterRegistry meterRegistry; + private EntitlementGuard guard; + + private final ObjectMapper json = new ObjectMapper(); + + @BeforeEach + void setUp() { + entitlementService = Mockito.mock(EntitlementService.class); + userRepository = Mockito.mock(UserRepository.class); + meterRegistry = new SimpleMeterRegistry(); + guard = new EntitlementGuard(entitlementService, userRepository, meterRegistry); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // --------------------------------------------------------------------------------------- + // Scope: non-AutoJobPostMapping routes skip the guard entirely + // --------------------------------------------------------------------------------------- + + @Test + void nonHandlerMethod_passesThrough() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, "someRawHandler"); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void routeWithNeitherAnnotation_isSkipped() throws Exception { + HandlerMethod hm = handlerFor("plainEndpoint"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + Mockito.verifyNoInteractions(entitlementService, userRepository); + } + + @Test + void routeWithRequiresFeatureButNoAutoJobPostMapping_isInScope() throws Exception { + // Regression: @RequiresFeature alone (e.g. JSON-bodied AI controllers) must be in-scope. + // Previously the guard short-circuited unless @AutoJobPostMapping was present, which meant + // a team without AI entitlement could hit /api/v1/ai/* freely. + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("aiOnlyNoAutoJobPostMapping"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); + assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); + verify(entitlementService).getSnapshot(42L); + } + + @Test + void routeWithClassLevelRequiresFeatureOnly_isInScope() throws Exception { + // Mirrors AiCreateController shape: @RequiresFeature lives on the @RestController class, + // not the method. Must still be picked up by the guard. + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerForClassLevel("classLevelAiMethod"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(401); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); + assertThat(body.get("category").asText()).isEqualTo("AI"); + } + + // --------------------------------------------------------------------------------------- + // Anonymous user + // --------------------------------------------------------------------------------------- + + @Test + void anonymousUser_billableRoute_returns401SignupRequired() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("automationOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(401); + assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED"); + assertThat(body.get("category").asText()).isEqualTo("AUTOMATION"); + Mockito.verifyNoInteractions(entitlementService); + } + + @Test + void anonymousUser_aiRoute_returns401WithAiCategory() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("aiOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("category").asText()).isEqualTo("AI"); + } + + @Test + void anonymousUser_manualTool_passesThroughUnbilled() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")))); + + HandlerMethod hm = handlerFor("manualTool"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + Mockito.verifyNoInteractions(entitlementService); + } + + // --------------------------------------------------------------------------------------- + // Authenticated user — FULL vs DEGRADED + // --------------------------------------------------------------------------------------- + + @Test + void authenticatedUser_fullState_passesThrough() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot()); + + HandlerMethod hm = handlerFor("automationOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void authenticatedUser_degradedAndBillable_returns402() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("automationOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED"); + assertThat(body.get("state").asText()).isEqualTo("DEGRADED"); + assertThat(body.get("capUnits").asLong()).isEqualTo(500L); + assertThat(body.get("spendUnits").asLong()).isEqualTo(500L); + assertThat(body.get("missingGates").isArray()).isTrue(); + assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION"); + } + + @Test + void authenticatedUser_degradedButManualTool_passesThrough() throws Exception { + // KEY assertion: DEGRADED+MINIMAL must still allow manual server tools (OFFSITE_PROCESSING) + // — that's the whole point of moving OFFSITE into MINIMAL. + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("manualTool"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void authenticatedUser_aiRouteDegraded_returns402() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot()); + + HandlerMethod hm = handlerFor("aiOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT"); + } + + // --------------------------------------------------------------------------------------- + // API-key calls: billable, hard-stop when degraded (allowance/cap reached) + // --------------------------------------------------------------------------------------- + + @Test + void apiKeyCall_degradedFreeTeam_returns402AndAdvisesSubscribe() throws Exception { + // A plain server tool (OFFSITE_PROCESSING) reached via API key: the gate survives DEGRADED, + // so the gate loop would wave it through — but API usage is billable and must hard-stop + // once the free allowance is spent. + SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false)); + + HandlerMethod hm = handlerFor("manualTool"); // OFFSITE default gate + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED"); + assertThat(body.get("subscribed").asBoolean()).isFalse(); + assertThat(body.get("message").asText()).contains("Subscribe"); + } + + @Test + void apiKeyCall_degradedSubscribedTeam_returns402AndAdvisesCap() throws Exception { + SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(true)); + + HandlerMethod hm = handlerFor("manualTool"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isFalse(); + assertThat(res.getStatus()).isEqualTo(402); + JsonNode body = json.readTree(res.getContentAsByteArray()); + assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED"); + assertThat(body.get("subscribed").asBoolean()).isTrue(); + assertThat(body.get("message").asText()).contains("cap"); + } + + @Test + void apiKeyCall_withinAllowance_passes() throws Exception { + // Not degraded → API usage under the free allowance proceeds normally. + SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L)); + when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot()); + + HandlerMethod hm = handlerFor("manualTool"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void jwtWebManualTool_degraded_stillPasses() throws Exception { + // The "allow JWT web tool usage" guarantee: a web (JWT) user running an everyday server + // tool (OFFSITE_PROCESSING) is NOT blocked when the team is over allowance — only billable + // API/AI/automation calls hard-stop. (Web manual calls are BillingCategory.BYPASSED.) + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false)); + + HandlerMethod hm = handlerFor("manualTool"); // OFFSITE — survives DEGRADED + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + // --------------------------------------------------------------------------------------- + // Fail-open + // --------------------------------------------------------------------------------------- + + @Test + void snapshotLookupThrows_failsOpenAndPasses() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + when(userRepository.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(7L, 42L))); + when(entitlementService.getSnapshot(42L)) + .thenThrow(new RuntimeException("transient DB outage")); + + HandlerMethod hm = handlerFor("automationOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + assertThat(res.getStatus()).isEqualTo(200); + } + + @Test + void noTeam_passesThrough() throws Exception { + UUID supabaseId = UUID.randomUUID(); + SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId)); + User u = new User(); + u.setId(7L); + u.setTeam(null); + when(userRepository.findBySupabaseId(supabaseId)).thenReturn(Optional.of(u)); + + HandlerMethod hm = handlerFor("automationOnly"); + MockHttpServletRequest req = new MockHttpServletRequest(); + MockHttpServletResponse res = new MockHttpServletResponse(); + + boolean proceed = guard.preHandle(req, res, hm); + + assertThat(proceed).isTrue(); + verify(entitlementService, never()).getSnapshot(any()); + } + + // --------------------------------------------------------------------------------------- + // resolveRequiredGates fallback + // --------------------------------------------------------------------------------------- + + @Test + void resolveRequiredGates_noAnnotation_defaultsToOffsiteProcessing() throws Exception { + HandlerMethod hm = handlerFor("manualTool"); + FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm); + assertThat(gates).containsExactly(FeatureGate.OFFSITE_PROCESSING); + } + + @Test + void resolveRequiredGates_withAnnotation_usesAnnotationValue() throws Exception { + HandlerMethod hm = handlerFor("automationOnly"); + FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm); + assertThat(gates).containsExactly(FeatureGate.AUTOMATION); + } + + // --------------------------------------------------------------------------------------- + // Helpers / fixture controller + // --------------------------------------------------------------------------------------- + + private static EntitlementSnapshot fullSnapshot() { + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of( + FeatureGate.OFFSITE_PROCESSING, + FeatureGate.AUTOMATION, + FeatureGate.AI_SUPPORT, + FeatureGate.CLIENT_SIDE), + 0L, + 500L, + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 7, 1, 0, 0), + false); + } + + private static EntitlementSnapshot degradedSnapshot() { + return degradedSnapshot(false); + } + + private static EntitlementSnapshot degradedSnapshot(boolean subscribed) { + return new EntitlementSnapshot( + EntitlementState.DEGRADED, + FeatureSet.MINIMAL, + List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE), + 500L, + 500L, + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 7, 1, 0, 0), + subscribed); + } + + private static ApiKeyAuthenticationToken apiKeyAuth(long teamId) { + User u = userWithTeam(99L, teamId); + return new ApiKeyAuthenticationToken( + u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + private static User userWithTeam(long userId, long teamId) { + User u = new User(); + u.setId(userId); + Team t = new Team(); + t.setId(teamId); + u.setTeam(t); + return u; + } + + private static EnhancedJwtAuthenticationToken jwtAuth(UUID supabaseId) { + Map headers = new HashMap<>(); + headers.put("alg", "RS256"); + Map claims = new HashMap<>(); + claims.put("sub", supabaseId.toString()); + claims.put("email", "user@example.com"); + Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims); + return new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "user@example.com", + supabaseId.toString()); + } + + private static HandlerMethod handlerFor(String methodName) throws NoSuchMethodException { + Method m = TestController.class.getDeclaredMethod(methodName); + return new HandlerMethod(new TestController(), m); + } + + private static HandlerMethod handlerForClassLevel(String methodName) + throws NoSuchMethodException { + Method m = ClassLevelAiController.class.getDeclaredMethod(methodName); + return new HandlerMethod(new ClassLevelAiController(), m); + } + + /** Fixture mounting route shapes the guard's resolver needs to discriminate. */ + static class TestController { + + @AutoJobPostMapping("/manual") + public String manualTool() { + return "ok"; + } + + @AutoJobPostMapping("/automation") + @RequiresFeature(FeatureGate.AUTOMATION) + public String automationOnly() { + return "ok"; + } + + @AutoJobPostMapping("/ai") + @RequiresFeature(FeatureGate.AI_SUPPORT) + public String aiOnly() { + return "ok"; + } + + /** Endpoint without any annotation — guard must skip. */ + public String plainEndpoint() { + return "ok"; + } + + /** AI-controller shape: @RequiresFeature with NO @AutoJobPostMapping (JSON body). */ + @RequiresFeature(FeatureGate.AI_SUPPORT) + public String aiOnlyNoAutoJobPostMapping() { + return "ok"; + } + } + + /** + * Mirrors {@code AiCreateController} layout: @RequiresFeature on the class, plain methods. The + * guard must pick up the class-level annotation. + */ + @RequiresFeature(FeatureGate.AI_SUPPORT) + static class ClassLevelAiController { + public String classLevelAiMethod() { + return "ok"; + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java new file mode 100644 index 000000000..430e1b7e0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java @@ -0,0 +1,271 @@ +package stirling.software.saas.payg.entitlement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import stirling.software.saas.payg.billing.TeamBillingContext; +import stirling.software.saas.payg.billing.TeamBillingService; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; +import stirling.software.saas.payg.repository.WalletLedgerRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 — the free allowance + * is a one-time lifetime grant): + * + *

    + *
  • Unsubscribed — gated by the grant. Cap = grant size, spend = {@code grant − + * remaining}, both read straight from the billing context (no ledger query). Exhausted grant + * (remaining ≤ 0) → DEGRADED. + *
  • Subscribed — gated by the monthly money-derived doc cap. Spend = this period's net + * billable units ({@link WalletLedgerRepository#sumPeriodNetBillable} negated, refunds + * netted). + *
+ * + * Also covers cache hit/miss + the invalidate cascade. + */ +class EntitlementServiceTest { + + private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 6, 9, 0, 0); + private static final LocalDateTime PERIOD_END = LocalDateTime.of(2026, 7, 9, 0, 0); + + private TeamBillingService billingService; + private WalletPolicyRepository walletPolicyRepo; + private WalletLedgerRepository ledgerRepo; + private EntitlementService service; + + @BeforeEach + void setUp() { + billingService = Mockito.mock(TeamBillingService.class); + walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class); + ledgerRepo = Mockito.mock(WalletLedgerRepository.class); + service = new EntitlementService(billingService, walletPolicyRepo, ledgerRepo); + } + + @Test + void getSnapshot_nullTeamId_throws() { + assertThatThrownBy(() -> service.getSnapshot(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void freeTeam_capIsTheGrantAndSpendIsUsedFromCounter() { + // Unsubscribed: cap = grant size, spend = grant − remaining — no ledger read. + stubBilling(42L, freeContext(500L, 400L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.periodCapUnits()).isEqualTo(500L); + assertThat(snap.periodSpendUnits()).isEqualTo(100L); + // 100/500 = 20% — well below warn → FULL + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); + // The grant gate doesn't touch the ledger at all. + Mockito.verifyNoInteractions(ledgerRepo); + } + + @Test + void subscribedTeam_capIsTheMoneyDerivedDocCap() { + stubBilling(42L, subscribedContext(2000L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-500L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.periodCapUnits()).isEqualTo(2000L); + assertThat(snap.periodSpendUnits()).isEqualTo(500L); + // 500/2000 = 25% — FULL + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void subscribedTeam_refundsNetAgainstSpend() { + // Net billable = debits − refunds. A −300 net (e.g. 500 debited, 200 refunded) → 300 spend. + stubBilling(42L, subscribedContext(2000L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-300L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.periodSpendUnits()).isEqualTo(300L); + } + + @Test + void spendWindow_comesFromBillingContextNotCalendarMonth() { + stubBilling(42L, subscribedContext(2000L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + // The subscription-anchored window flows through to both the snapshot and the SUM query. + assertThat(snap.periodStart()).isEqualTo(PERIOD_START); + assertThat(snap.periodEnd()).isEqualTo(PERIOD_END); + verify(ledgerRepo).sumPeriodNetBillable(eq(42L), eq(PERIOD_START), eq(PERIOD_END)); + } + + @Test + void exhaustedGrant_returnsDegradedWithMinimalGates() { + // Grant fully consumed (remaining 0) → billable categories hard-stop for an unsubscribed + // team. The displayed cap stays the grant size; spend reads as the full grant. + stubBilling(42L, freeContext(100L, 0L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(snap.periodCapUnits()).isEqualTo(100L); + assertThat(snap.periodSpendUnits()).isEqualTo(100L); + // MINIMAL now keeps OFFSITE_PROCESSING + CLIENT_SIDE (manual tools); AUTOMATION + AI gone. + assertThat(snap.enabledGates()) + .containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE); + assertThat(snap.enabledGates()) + .doesNotContain(FeatureGate.AUTOMATION, FeatureGate.AI_SUPPORT); + } + + @Test + void grantInWarnBand_returnsWarnedButFullFeatureSet() { + // grant 100, remaining 15 → used 85 = 85% (between warn 80 and degrade 100). + stubBilling(42L, freeContext(100L, 15L)); + when(walletPolicyRepo.findByTeamId(42L)) + .thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL))); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.state()).isEqualTo(EntitlementState.WARNED); + assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(snap.enabledGates()).hasSize(4); + } + + @Test + void uncappedSubscribedTeam_nullCapNeverDegrades() { + stubBilling(42L, subscribedContext(null)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1_000_000L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.periodCapUnits()).isNull(); + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void positiveNetBillable_treatedAsZeroSpend() { + // Subscribed defensive: if refunds exceed debits (positive net), spend clamps to zero. + stubBilling(42L, subscribedContext(100L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(50L); + + EntitlementSnapshot snap = service.getSnapshot(42L); + + assertThat(snap.periodSpendUnits()).isZero(); + assertThat(snap.state()).isEqualTo(EntitlementState.FULL); + } + + @Test + void cacheHit_secondCallSkipsLedgerLookup() { + stubBilling(42L, subscribedContext(500L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); + + service.getSnapshot(42L); + service.getSnapshot(42L); + service.getSnapshot(42L); + + // Only one underlying ledger SUM despite 3 calls — second + third hit the cache. + verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any()); + assertThat(service.cacheSize()).isEqualTo(1); + } + + @Test + void invalidate_dropsCacheAndCascadesToBillingService() { + stubBilling(42L, subscribedContext(500L)); + when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L); + + service.getSnapshot(42L); + service.invalidate(42L); + service.getSnapshot(42L); + + verify(ledgerRepo, times(2)).sumPeriodNetBillable(eq(42L), any(), any()); + // Window/cap facts must recompute together with the spend. + verify(billingService).invalidate(42L); + } + + @Test + void invalidate_otherTeamLeavesEntryAlone() { + when(billingService.forTeam(any())).thenReturn(subscribedContext(500L)); + when(walletPolicyRepo.findByTeamId(any())).thenReturn(Optional.empty()); + when(ledgerRepo.sumPeriodNetBillable(any(), any(), any())).thenReturn(0L); + + service.getSnapshot(42L); + service.invalidate(99L); + service.getSnapshot(42L); + + // Only one fetch for team 42 — 99 invalidate didn't touch its entry. + verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any()); + } + + @Test + void currentMonthWindow_isStartOfMonthInclusiveToStartOfNextMonthExclusive() { + LocalDateTime mid = LocalDateTime.of(2026, 6, 15, 14, 30); + LocalDateTime[] w = EntitlementService.currentMonthWindow(mid); + assertThat(w[0]).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0)); + assertThat(w[1]).isEqualTo(LocalDateTime.of(2026, 7, 1, 0, 0)); + } + + private void stubBilling(Long teamId, TeamBillingContext ctx) { + when(billingService.forTeam(teamId)).thenReturn(ctx); + } + + /** Unsubscribed team: gated by the one-time grant (size + remaining); no monthly cap. */ + private static TeamBillingContext freeContext(long grant, long remaining) { + return new TeamBillingContext( + false, null, PERIOD_START, PERIOD_END, grant, remaining, null, null, null, null); + } + + /** + * Subscribed team: monthly money-derived paid-doc cap (null = uncapped); grant treated as + * exhausted (remaining 0 — doesn't gate a paying team). + */ + private static TeamBillingContext subscribedContext(Long monthlyCapDocUnits) { + return new TeamBillingContext( + true, + "sub_test", + PERIOD_START, + PERIOD_END, + 500L, + 0L, + java.math.BigDecimal.valueOf(2), + "usd", + monthlyCapDocUnits == null ? null : monthlyCapDocUnits * 2, + monthlyCapDocUnits); + } + + private static WalletPolicy walletPolicyThresholds(FeatureSet degradedSet) { + WalletPolicy p = new WalletPolicy(); + p.setDegradedFeatureSet(degradedSet); + p.setWarnAtPct(80); + p.setDegradeAtPct(100); + return p; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index 72af9368e..281b039ca 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -130,7 +130,8 @@ class PaygChargeInterceptorTest { @Test void preHandle_openedDisposition_stashesJobIdAndDisposition() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + // API-key auth → BillingCategory.API → billable path engaged. + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 4, ChargeOutcome.Disposition.OPENED)); @@ -153,7 +154,8 @@ class PaygChargeInterceptorTest { @Test void preHandle_chargeServiceThrows_failsOpenAndIncrementsErrorCounter() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + // API-key auth → API category → reaches openProcess so the throw can be observed. + authenticateWithApiKey(makeUser(7L, 42L)); when(chargeService.openProcess(any(), anyList())).thenThrow(new RuntimeException("boom")); MockMultipartHttpServletRequest req = newMultipart(); @@ -170,7 +172,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_2xx_appendsStepAndRecordsOutputs() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -203,11 +205,33 @@ class PaygChargeInterceptorTest { verify(jobService).recordOutput(eq(jobId), any()); verify(chargeService, never()).markFirstStepFailed(any(), any()); verify(chargeService, never()).decrementStepCount(any()); + // Success on an OPENED process is the primary meter trigger — fires now, not at close. + verify(chargeService).meterJobUsage(jobId); + } + + @Test + void afterCompletion_2xx_joined_doesNotMeter() throws Exception { + // A JOINED follow-up step (chained tool on the same document) added no units when it + // joined — it must not re-meter; the OPENED step already did. + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED)); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + MockHttpServletResponse res = new MockHttpServletResponse(); + res.setStatus(200); + + interceptor.preHandle(req, res, handlerMethodForFakeController()); + interceptor.afterCompletion(req, res, handlerMethodForFakeController(), null); + + verify(chargeService, never()).meterJobUsage(any()); } @Test void afterCompletion_5xx_opened_callsMarkFirstStepFailed() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -226,11 +250,13 @@ class PaygChargeInterceptorTest { verify(jobService) .appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("503")); assertThat(meterRegistry.counter("payg.filter.refunds").count()).isEqualTo(1.0); + // First-step failure refunds — never meter it. + verify(chargeService, never()).meterJobUsage(any()); } @Test void afterCompletion_5xx_joined_callsDecrementStepCount() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED)); @@ -249,7 +275,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_4xx_appendsFailedStepNoRefundNoOutputs() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -267,6 +293,8 @@ class PaygChargeInterceptorTest { verify(jobService, never()).recordOutput(any(), any()); verify(jobService) .appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("422")); + // 4xx is a full charge (customer paid for the attempt), so it still meters. + verify(chargeService).meterJobUsage(jobId); } @Test @@ -293,7 +321,7 @@ class PaygChargeInterceptorTest { @Test void afterCompletion_maxBytesExceeded_skipsOutputRecording() throws Exception { properties.getResponse().setMaxBytes(2L); - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -317,7 +345,10 @@ class PaygChargeInterceptorTest { @Test void preHandle_desktopClientHeader_setsJobSourceDesktopApp() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + // API-key (billable) so the call reaches openProcess; the desktop header still wins the + // source mapping (checked before the API-key branch in determineSource). A manual JWT call + // is BYPASSED and never opens a process, so source wouldn't be recorded. + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -338,7 +369,9 @@ class PaygChargeInterceptorTest { @Test void preHandle_toolId_prefersBestMatchingPattern() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + // API-key (billable) so doPreHandle runs and records tool_id; a manual JWT call is + // BYPASSED before that point, so no tool_id is stored. + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -359,7 +392,9 @@ class PaygChargeInterceptorTest { @Test void preHandle_toolId_truncatesAndCountsWhenLongerThan128() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + // API-key (billable) so doPreHandle runs and records tool_id (a manual JWT call is + // BYPASSED). + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -380,7 +415,7 @@ class PaygChargeInterceptorTest { @Test void preHandle_pipelineHeader_setsJobSourcePipeline() throws Exception { - authenticateWithUser(makeUser(7L, 42L)); + authenticateWithApiKey(makeUser(7L, 42L)); UUID jobId = UUID.randomUUID(); when(chargeService.openProcess(any(), anyList())) .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); @@ -399,6 +434,184 @@ class PaygChargeInterceptorTest { .isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE); } + // --- BillingCategory categorisation + bypass fast-path ------------------------------------- + + @Test + void preHandle_manualToolJwt_isBypassedAndSkipsOpenProcess() throws Exception { + // JWT-authenticated, plain @AutoJobPostMapping endpoint, no automation header → BYPASSED. + // The interceptor must skip openProcess entirely (no temp files, no DB writes) and bump + // the payg.filter.bypassed counter. + authenticateWithUser(makeUser(7L, 42L)); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + boolean cont = + interceptor.preHandle( + req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + assertThat(cont).isTrue(); + verify(chargeService, never()).openProcess(any(), anyList()); + assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_JOB_ID)).isNull(); + assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_INPUT_TEMP_FILES)).isNull(); + assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(1.0); + } + + @Test + void preHandle_apiKeyAuth_setsBillingCategoryApi() throws Exception { + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.API); + } + + @Test + void preHandle_requiresFeatureAutomation_setsBillingCategoryAutomation() throws Exception { + // JWT auth on a @RequiresFeature(AUTOMATION) endpoint → AUTOMATION category. + authenticateWithUser(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAutomation()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); + } + + @Test + void preHandle_requiresFeatureAiSupport_setsBillingCategoryAi() throws Exception { + // JWT auth on a @RequiresFeature(AI_SUPPORT) endpoint → AI category. + authenticateWithUser(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); + } + + @Test + void preHandle_requiresFeatureWithoutAutoJobPostMapping_reachesCategoryGate() throws Exception { + // Regression: AI controllers carry @RequiresFeature but NO @AutoJobPostMapping. They must + // still flow past the short-circuit gate so determineCategory runs (and so future + // multipart-bearing @RequiresFeature routes bill correctly). API-key auth + + // @RequiresFeature + // — even without multipart inputs — should land in the BillingCategory.API branch via + // determineCategory's auth check, then short-circuit inside doPreHandle because there are + // no multipart parts. + authenticateWithApiKey(makeUser(7L, 42L)); + MockMultipartHttpServletRequest req = newMultipart(); + // No file parts — emulates a JSON-bodied AI controller request that happens to be wrapped + // as multipart. doPreHandle short-circuits with no openProcess call. + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[0])); + + boolean cont = + interceptor.preHandle( + req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob()); + + assertThat(cont).isTrue(); + verify(chargeService, never()).openProcess(any(), anyList()); + // Importantly: not counted as BYPASSED — the AI category was determined correctly. + assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(0.0); + } + + @Test + void preHandle_aiEndpointWithoutAutoJobPostMapping_categoryIsAi() throws Exception { + // With multipart parts present + @RequiresFeature(AI_SUPPORT) but no @AutoJobPostMapping: + // the interceptor must run determineCategory and tag the ChargeContext as AI. + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); + } + + @Test + void preHandle_classLevelRequiresFeatureOnly_isInScope() throws Exception { + // Mirrors AiCreateController shape: @RequiresFeature on the @RestController class. + // The interceptor must resolve it via beanType lookup and not short-circuit as + // "no annotation". + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForClassLevelAi()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI); + } + + @Test + void preHandle_aiEndpointWithAutomationHeader_automationWinsByPrecedence() throws Exception { + // X-Stirling-Automation: true on an @RequiresFeature(AI_SUPPORT) endpoint → AUTOMATION + // (header beats annotation by design — pipeline-driven AI counts as automation usage). + authenticateWithUser(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Automation", "true"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().billingCategory()) + .isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION); + } + // --- helpers -------------------------------------------------------------------------------- private MockMultipartHttpServletRequest newMultipart() { @@ -459,11 +672,78 @@ class PaygChargeInterceptorTest { } } + private static HandlerMethod handlerMethodForAutomation() { + try { + Method m = FakeController.class.getDeclaredMethod("handleAutomation"); + return new HandlerMethod(new FakeController(), m); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static HandlerMethod handlerMethodForAi() { + try { + Method m = FakeController.class.getDeclaredMethod("handleAi"); + return new HandlerMethod(new FakeController(), m); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static HandlerMethod handlerMethodForAiNoAutoJob() { + try { + Method m = FakeController.class.getDeclaredMethod("handleAiNoAutoJob"); + return new HandlerMethod(new FakeController(), m); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static HandlerMethod handlerMethodForClassLevelAi() { + try { + Method m = ClassLevelAiController.class.getDeclaredMethod("classLevelAi"); + return new HandlerMethod(new ClassLevelAiController(), m); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private void authenticateWithApiKey(User user) { + stirling.software.proprietary.security.model.ApiKeyAuthenticationToken token = + new stirling.software.proprietary.security.model.ApiKeyAuthenticationToken( + user, "test-api-key", List.of(new SimpleGrantedAuthority("ROLE_API"))); + SecurityContextHolder.getContext().setAuthentication(token); + } + static class FakeController { @AutoJobPostMapping(value = "/x", resourceWeight = 1) public void handleAuto() {} public void handlePlain() {} + + @AutoJobPostMapping(value = "/auto", resourceWeight = 1) + @stirling.software.saas.payg.cap.RequiresFeature( + stirling.software.saas.payg.model.FeatureGate.AUTOMATION) + public void handleAutomation() {} + + @AutoJobPostMapping(value = "/ai", resourceWeight = 1) + @stirling.software.saas.payg.cap.RequiresFeature( + stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) + public void handleAi() {} + + /** + * AI-controller shape: @RequiresFeature without @AutoJobPostMapping (JSON body / proxy). + */ + @stirling.software.saas.payg.cap.RequiresFeature( + stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) + public void handleAiNoAutoJob() {} + } + + /** Mirrors AiCreateController layout: @RequiresFeature on the class, plain methods. */ + @stirling.software.saas.payg.cap.RequiresFeature( + stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT) + static class ClassLevelAiController { + public void classLevelAi() {} } /** Placeholder so AutoCloseable resources flow in some helper methods. */ diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java index e526bf7ca..6f921a6fd 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java @@ -1,35 +1,72 @@ package stirling.software.saas.payg.job; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; +import java.util.UUID; + import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import stirling.software.saas.payg.charge.JobChargeService; + /** - * Smoke test for the scheduler wiring. The interesting close logic is exercised in {@link - * JobServiceTest#closeStale_closesAllStaleJobs}; here we just confirm the scheduler bean delegates - * to {@code JobService.closeStale} and tolerates an empty result without erroring. + * Smoke test for the scheduler wiring. Confirms the scheduler closes each stale job through {@link + * JobChargeService#close} (so the Stripe meter afterCommit hook fires per job), isolates per-job + * failures, and tolerates an empty stale set without erroring. The close/meter logic itself is + * exercised in {@code JobChargeServiceTest}. */ class StaleJobCloserTest { - @Test - void closeStale_invokesJobService() { - JobService jobService = Mockito.mock(JobService.class); - when(jobService.closeStale()).thenReturn(3); - - new StaleJobCloser(jobService).closeStale(); - - verify(jobService).closeStale(); + private static ProcessingJob job(UUID id) { + ProcessingJob j = new ProcessingJob(); + j.setId(id); + return j; } @Test - void closeStale_zeroClosedDoesNotThrow() { + void closeStale_closesEachStaleJobThroughChargeService() { JobService jobService = Mockito.mock(JobService.class); - when(jobService.closeStale()).thenReturn(0); + JobChargeService chargeService = Mockito.mock(JobChargeService.class); + UUID a = UUID.randomUUID(); + UUID b = UUID.randomUUID(); + when(jobService.findStale()).thenReturn(List.of(job(a), job(b))); - new StaleJobCloser(jobService).closeStale(); + new StaleJobCloser(jobService, chargeService).closeStale(); - verify(jobService).closeStale(); + // Routed through the charge service (meter hook), NOT the bulk closeStale flip. + verify(chargeService).close(a); + verify(chargeService).close(b); + verify(jobService, never()).closeStale(); + } + + @Test + void closeStale_oneJobFailing_doesNotStrandTheRest() { + JobService jobService = Mockito.mock(JobService.class); + JobChargeService chargeService = Mockito.mock(JobChargeService.class); + UUID bad = UUID.randomUUID(); + UUID good = UUID.randomUUID(); + when(jobService.findStale()).thenReturn(List.of(job(bad), job(good))); + when(chargeService.close(bad)).thenThrow(new RuntimeException("boom")); + + // Must not propagate — the sweep continues to the next job. + new StaleJobCloser(jobService, chargeService).closeStale(); + + verify(chargeService).close(bad); + verify(chargeService).close(good); + } + + @Test + void closeStale_emptyStaleSet_doesNotTouchChargeService() { + JobService jobService = Mockito.mock(JobService.class); + JobChargeService chargeService = Mockito.mock(JobChargeService.class); + when(jobService.findStale()).thenReturn(List.of()); + + new StaleJobCloser(jobService, chargeService).closeStale(); + + verify(chargeService, never()).close(any()); } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java new file mode 100644 index 000000000..f9ab02c3d --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java @@ -0,0 +1,121 @@ +package stirling.software.saas.payg.meter; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.Pageable; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; + +/** + * Unit tests for {@link PaygMeterReconcileScheduler}: retries unposted events for still-subscribed + * teams under the same idempotency key, skips teams that have since unsubscribed, and no-ops when + * disabled. + */ +class PaygMeterReconcileSchedulerTest { + + private PaygMeterEventLogRepository logRepo; + private PaygTeamExtensionsRepository teamExtRepo; + private PaygMeterReportingService meterReportingService; + private MeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + logRepo = Mockito.mock(PaygMeterEventLogRepository.class); + teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class); + meterReportingService = Mockito.mock(PaygMeterReportingService.class); + meterRegistry = new SimpleMeterRegistry(); + when(logRepo.countStuck(any())).thenReturn(0L); + } + + private PaygMeterReconcileScheduler scheduler(boolean enabled) { + return new PaygMeterReconcileScheduler( + logRepo, + teamExtRepo, + meterReportingService, + enabled, + Duration.ofMinutes(5), + 100, + meterRegistry); + } + + private static PaygMeterEventLog row(Long teamId, UUID jobId, String key, int units) { + PaygMeterEventLog e = new PaygMeterEventLog(); + e.setTeamId(teamId); + e.setJobId(jobId); + e.setIdempotencyKey(key); + e.setUnits(units); + return e; + } + + private static PaygTeamExtensions ext(Long teamId, String customerId, String subscriptionId) { + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(teamId); + ext.setStripeCustomerId(customerId); + ext.setPaygSubscriptionId(subscriptionId); + return ext; + } + + @Test + void reconcile_subscribedTeam_reMetersUnderSameKey() { + UUID jobId = UUID.randomUUID(); + when(logRepo.findRetryable(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); + when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", "sub_live"))); + + scheduler(true).reconcile(); + + verify(meterReportingService) + .recordUsage(100L, "cus_live", 4, null, "process:" + jobId + ":close", jobId); + } + + @Test + void reconcile_teamUnsubscribedSince_isSkipped() { + UUID jobId = UUID.randomUUID(); + when(logRepo.findRetryable(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); + // Customer still present but no live subscription → no longer billable. + when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", null))); + + scheduler(true).reconcile(); + + verify(meterReportingService, never()) + .recordUsage(any(), any(), anyInt(), any(), any(), any()); + } + + @Test + void reconcile_missingTeamExtensions_isSkipped() { + UUID jobId = UUID.randomUUID(); + when(logRepo.findRetryable(any(), any(), any(Pageable.class))) + .thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4))); + when(teamExtRepo.findAllById(any())).thenReturn(List.of()); + + scheduler(true).reconcile(); + + verify(meterReportingService, never()) + .recordUsage(any(), any(), anyInt(), any(), any(), any()); + } + + @Test + void reconcile_disabled_isNoOp() { + scheduler(false).reconcile(); + + verifyNoInteractions(logRepo, teamExtRepo, meterReportingService); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java new file mode 100644 index 000000000..7ee240827 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java @@ -0,0 +1,245 @@ +package stirling.software.saas.payg.meter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.net.ConnectException; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.repository.PaygMeterEventLogRepository; + +/** + * Covers the contract documented on {@link PaygMeterReportingService#recordUsage}: never throws, + * skips when endpoint is blank, counts non-2xx and exceptions on {@code payg.meter.errors}, and + * wraps every POST in a durable {@code payg_meter_event_log} row (pending → posted / failed). + */ +class PaygMeterReportingServiceTest { + + private static final String ENDPOINT = + "https://example.supabase.co/functions/v1/meter-payg-units"; + private static final String TOKEN = "test-service-role-token"; + private static final UUID JOB = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + + private RestTemplate restTemplate; + private PaygMeterEventLogRepository eventLogRepository; + private MeterRegistry meterRegistry; + private Counter errorsCounter; + + @BeforeEach + void setUp() { + restTemplate = Mockito.mock(RestTemplate.class); + eventLogRepository = Mockito.mock(PaygMeterEventLogRepository.class); + meterRegistry = new SimpleMeterRegistry(); + errorsCounter = meterRegistry.counter("payg.meter.errors"); + } + + private PaygMeterReportingService newService(String endpoint, String token) { + return new PaygMeterReportingService( + endpoint, token, restTemplate, eventLogRepository, meterRegistry); + } + + @Test + void recordUsage_happyPath_postsBodyLogsPendingThenPosted() { + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK)); + + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job1:close", JOB); + + @SuppressWarnings("unchecked") + ArgumentCaptor>> entityCaptor = + ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate) + .exchange( + eq(ENDPOINT), + eq(HttpMethod.POST), + entityCaptor.capture(), + eq(String.class)); + + HttpEntity> sent = entityCaptor.getValue(); + Map body = sent.getBody(); + assertThat(body).isNotNull(); + // JSON number — the edge fn type-checks team_id and ignores strings. + assertThat(body.get("team_id")).isEqualTo(100L); + assertThat(body.get("stripe_customer_id")).isEqualTo("cus_abc"); + assertThat(body.get("units")).isEqualTo(5); + assertThat(body.get("idempotency_key")).isEqualTo("process:job1:close"); + assertThat(body.get("metadata")).isEqualTo(Map.of("category", "API")); + + HttpHeaders headers = sent.getHeaders(); + assertThat(headers.getFirst("Authorization")).isEqualTo("Bearer " + TOKEN); + assertThat(headers.getContentType()).isNotNull(); + assertThat(headers.getContentType().toString()).startsWith("application/json"); + + // Durable audit: pending row written before the POST, stamped posted after success. + verify(eventLogRepository).insertPending(100L, JOB, "process:job1:close", 5); + verify(eventLogRepository).markPosted("process:job1:close"); + verify(eventLogRepository, never()).markFailed(any(), any(), any()); + assertThat(errorsCounter.count()).isZero(); + } + + @Test + void recordUsage_5xxResponse_marksFailedIncrementsErrorCounterAndDoesNotThrow() { + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>("oops", HttpStatus.INTERNAL_SERVER_ERROR)); + + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + assertThatCode( + () -> + service.recordUsage( + 100L, + "cus_abc", + 3, + BillingCategory.AUTOMATION, + "process:job2:close", + JOB)) + .doesNotThrowAnyException(); + + verify(eventLogRepository).insertPending(100L, JOB, "process:job2:close", 3); + verify(eventLogRepository).markFailed(eq("process:job2:close"), eq("500"), any()); + verify(eventLogRepository, never()).markPosted(any()); + assertThat(errorsCounter.count()).isEqualTo(1.0); + } + + @Test + void recordUsage_connectionRefused_marksFailedIncrementsErrorCounterAndDoesNotThrow() { + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenThrow(new ResourceAccessException("connect refused", new ConnectException())); + + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + assertThatCode( + () -> + service.recordUsage( + 100L, + "cus_abc", + 7, + BillingCategory.AI, + "process:job3:close", + JOB)) + .doesNotThrowAnyException(); + + verify(eventLogRepository).insertPending(100L, JOB, "process:job3:close", 7); + verify(eventLogRepository).markFailed(eq("process:job3:close"), eq("exception"), any()); + assertThat(errorsCounter.count()).isEqualTo(1.0); + } + + @Test + void recordUsage_runtimeException_swallowed() { + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenThrow(new RuntimeException("boom")); + + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + assertThatCode( + () -> + service.recordUsage( + 100L, + "cus_abc", + 7, + BillingCategory.AI, + "process:job4:close", + JOB)) + .doesNotThrowAnyException(); + + verify(eventLogRepository).markFailed(eq("process:job4:close"), eq("exception"), any()); + assertThat(errorsCounter.count()).isEqualTo(1.0); + } + + @Test + void recordUsage_logPendingFailure_stillPostsAndDoesNotThrow() { + // A DB hiccup writing the audit row must not stop us metering the customer. + Mockito.doThrow(new RuntimeException("db down")) + .when(eventLogRepository) + .insertPending(any(), any(), any(), anyInt()); + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>("{}", HttpStatus.OK)); + + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + assertThatCode( + () -> + service.recordUsage( + 100L, + "cus_abc", + 2, + BillingCategory.API, + "process:job9:close", + JOB)) + .doesNotThrowAnyException(); + + verify(restTemplate).exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)); + } + + @Test + void recordUsage_blankEndpoint_noopsAndDoesNotCallRestTemplateOrLog() { + PaygMeterReportingService service = newService("", TOKEN); + service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job5:close", JOB); + + verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); + verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); + assertThat(errorsCounter.count()).isZero(); + } + + @Test + void recordUsage_nullEndpoint_noopsAndDoesNotCallRestTemplate() { + PaygMeterReportingService service = newService(null, TOKEN); + service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job6:close", JOB); + + verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); + verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); + assertThat(errorsCounter.count()).isZero(); + } + + @Test + void recordUsage_zeroUnits_noopsAndDoesNotCallRestTemplateOrLog() { + PaygMeterReportingService service = newService(ENDPOINT, TOKEN); + service.recordUsage(100L, "cus_abc", 0, BillingCategory.API, "process:job7:close", JOB); + + verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class)); + verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt()); + assertThat(errorsCounter.count()).isZero(); + } + + @Test + void recordUsage_blankServiceRoleToken_postsWithoutAuthorizationHeader() { + when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class))) + .thenReturn(new ResponseEntity<>("{}", HttpStatus.OK)); + + PaygMeterReportingService service = newService(ENDPOINT, ""); + service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job8:close", JOB); + + @SuppressWarnings("unchecked") + ArgumentCaptor>> entityCaptor = + ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate, times(1)) + .exchange( + eq(ENDPOINT), + eq(HttpMethod.POST), + entityCaptor.capture(), + eq(String.class)); + assertThat(entityCaptor.getValue().getHeaders().getFirst("Authorization")).isNull(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java index bffc17bd9..f1a24d035 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -107,6 +107,16 @@ class PaygEntitiesSmokeTest { assertThat(entry.getAmountUnits()).isEqualTo(-4); } + @Test + void walletLedgerEntry_billingCategoryRoundTrips() { + WalletLedgerEntry entry = new WalletLedgerEntry(); + // Default (unset) is null — captured by both the legacy debit path and pre-V16 rows. + assertThat(entry.getBillingCategory()).isNull(); + + entry.setBillingCategory(BillingCategory.AUTOMATION); + assertThat(entry.getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION); + } + @Test void walletPolicy_carriesSensibleDefaults() { WalletPolicy policy = new WalletPolicy(); @@ -148,4 +158,29 @@ class PaygEntitiesSmokeTest { assertThat(row.getDiffPct()).isNegative(); } + + @Test + void paygShadowCharge_billingCategoryAndJobSourceRoundTrip() { + PaygShadowCharge row = new PaygShadowCharge(); + assertThat(row.getBillingCategory()).isNull(); + assertThat(row.getJobSource()).isNull(); + + row.setBillingCategory(BillingCategory.AI); + row.setJobSource(JobSource.API); + + assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.AI); + assertThat(row.getJobSource()).isEqualTo(JobSource.API); + } + + @Test + void billingCategory_listingOrderIsStable() { + // No downstream relies on ordinal() today, but the comment in the enum claims BYPASSED is + // declared first as the default sentinel — guard against an accidental reorder. + assertThat(BillingCategory.values()) + .containsExactly( + BillingCategory.BYPASSED, + BillingCategory.API, + BillingCategory.AI, + BillingCategory.AUTOMATION); + } } diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ff80bc948..e0745cad5 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3090,6 +3090,18 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" +[config.payg] +label = "Billing & usage" +section = "Pay-as-you-go" + +[connectionMode.status] +localOffline = "Offline mode running" +localOnline = "Offline mode running" +saas = "Connected to Stirling Cloud" +selfhostedChecking = "Connected to self-hosted server (checking...)" +selfhostedOffline = "Self-hosted server unreachable" +selfhostedOnline = "Connected to self-hosted server" + [convert] tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" autoRotate = "Auto Rotate" @@ -5132,6 +5144,239 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" +[pageSelection.tooltip.syntax] +text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported." +title = "Syntax Basics" + +[pageSelection.tooltip.syntax.bullets] +keywords = "Keywords: odd, even" +numbers = "Numbers/ranges: 5, 10-20" +progressions = "Progressions: 3n, 4n+1" + +[pageSelection.tooltip.tips] +bullet1 = "Page numbers start from 1 (not 0)" +bullet2 = "Spaces are automatically removed" +bullet3 = "Invalid expressions are ignored" +text = "Keep these guidelines in mind:" +title = "Tips" + +[payg] +subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} – {{end}}." + +[payg.activity] +subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." +title = "Recent billable activity" +units = "units" +viewAll = "View all" + +[payg.cap] +amount = "Cap amount" +currency = "Currency" +custom = "Custom" +degradeAt = "Limit spend at" +degradeAtDesc = "Set below 100% to leave yourself headroom." +docsEstimate = "≈ {{docs}} processed PDFs / month" +docsRate = "at {{rate}} / PDF" +noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." +noCapLabel = "No cap" +perMonth = "/ month" +preview = "{{money}} ≈ {{units}} PDFs per month" +previewNote = "At current pricing. Final translation happens server-side on save." +previewShort = "≈ {{units}} PDFs per month at current pricing." +save = "Update cap" +subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." +title = "Monthly spending cap" +warnAt = "Warn me at" +warnAtDesc = "Notify when usage crosses this threshold." + +[payg.checkout] +connecting = "Connecting to Stripe…" +errorTitle = "Stripe error" + +[payg.checkout.error] +startFailed = "Couldn't start checkout session" + +[payg.checkout.mock] +backend = "Backend is in mock mode — no real Stripe session was created." +continue = "Continue with mock subscription" +noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured." +title = "Stripe Embedded Checkout (mock mode)" + +[payg.confirm] +body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs." +capValue = "{{symbol}}{{amount}} / month" +noCap = "No cap" +note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page." +summaryLabel = "Monthly ceiling" +title = "Welcome to the Processor plan" + +[payg.error] +body = "We couldn't reach the billing service. Refresh the page to try again." +title = "Couldn't load your plan" + +[payg.exhausted] +body = "You've used all 500 of your free PDFs. Upgrade to Processor to keep going." +cta = "Go to billing" +title = "You've used your free PDFs" + +[payg.free.cta] +benefit1Body = "chain tools, schedule runs, batch process" +benefit1Title = "Automation pipelines" +benefit2Body = "summarise, classify, redact, AI-OCR" +benefit2Title = "AI tools" +benefit3Body = "call any Stirling endpoint programmatically" +benefit3Title = "API access" +button = "Turn on Processor →" +reassurance = "No minimum · Set a $0 cap to test · Cancel anytime" +subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control." +title = "Turn on the Processor plan" + +[payg.free.editor] +eyebrow = "Editor plan · Always free" + +[payg.free.explainer] +alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from." +alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted." +alwaysFreeForYouLabel = "Always free for you" +alwaysFreeLabel = "Always free" +countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor." +countsLabel = "Counts toward your 500 free" +sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor." +sharedLabel = "Shared with the team" + +[payg.free.header] +subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." + +[payg.free.hero] +capSuffix = "/ {{limit}} free PDFs" +eyebrow = "Your free PDFs" +metaCategories = "Automation · AI · API requests" +neverResets = "One-time — never resets" + +[payg.free.member] +body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access share the team's one-time allowance of 500 free PDFs." +ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." +title = "Want to process more than your 500 free PDFs?" + +[payg.free.proc] +eyebrow = "Processor plan · metered" + +[payg.free.state] +approachingLimit = "Approaching limit" +limitReached = "Limit reached" +plentyLeft = "Plenty left" + +[payg.gates] +ai = "AI tools (AI Create, suggestions, AI-OCR)" +automation = "Automations & pipelines" +client = "Browser-only tools (viewer, page editor, file management)" +offsite = "Server tools (compress, OCR, convert, watermark…)" +pauses = "pauses at cap" +subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised." +title = "What happens when the cap is reached" + +[payg.member] +askLeader = "Only your team owner can change the cap." + +[payg.members] +docs = "PDFs" +subtitle = "Billable PDFs each teammate has processed this period." +title = "Team member usage" + +[payg.role] +leader = "Team owner" +member = "Member" + +[payg.signupRequired] +body = "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing." +cancel = "Not now" +cta = "Sign up free" +subtext = "Creating an account is free and takes a few seconds. No credit card required." +title = "Sign up to use {{category}}" + +[payg.signupRequired.category] +ai = "AI features" +api = "this tool" +automation = "automations" +default = "this feature" + +[payg.state] +degraded = "Cap reached" +full = "Healthy" +warned = "Approaching cap" + +[payg.stripe] +open = "Open billing portal" +subtitle = "Receipts, invoices, payment method, billing currency." +title = "Manage billing in Stripe" + +[payg.stripe.toast.unavailable] +body = "Billing portal isn't available right now. Try again in a moment." +title = "Billing portal unavailable" + +[payg.upgrade] +closeAria = "Close" + +[payg.upgrade.button] +back = "← Back" +cancel = "Cancel" +continue = "Continue →" +finish = "Finish" + +[payg.upgrade.cap] +customPlaceholder = "Or enter your own amount ($0 keeps it free)" +help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." +noCapHint = "You can still cancel anytime from the customer portal." +noCapLabel = "No cap — I'll manage spend manually" +perMonthSuffix = "/ month" +presetsAria = "Monthly cap preset" +title = "Set your monthly spend ceiling" +usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency." + +[payg.upgrade.checkout] +capLabel = "Monthly ceiling:" +capValue = "{{symbol}}{{amount}} / month" +edit = "Edit" +help = "Stripe handles your card details. Stirling never sees them." +loading = "Loading checkout…" +noCap = "No cap" +title = "Add your payment method" + +[payg.upgrade.footer] +capHint = "You can change your cap any time later." +checkoutHint = "Card details handled by Stripe — never touched by Stirling." +confirmHint = "Your wallet will refresh automatically in a moment." + +[payg.upgrade.help] +aiBody = "summarise, classify, redact, AI-OCR" +aiTitle = "AI tools" +apiBody = "programmatic access to any Stirling endpoint" +apiTitle = "API calls" +automationBody = "chained tools or scheduled runs that don't need clicks" +automationTitle = "Automation pipelines" +footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click." +title = "What we count toward billing" + +[payg.upgrade.promise] +body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge." +highlight = "Manual tools stay free, always." + +[payg.upgrade.steps] +cap = "Set monthly ceiling" +payment = "Add payment method" + +[payg.upgrade.title] +confirm = "You're subscribed" +default = "Upgrade to Processor plan" + +[payg.usage] +credit = "{{amount}} account credit" +docs = "documents" +resetsIn = "Resets in {{days}} days" +resetsTomorrow = "Resets tomorrow" +spent = "{{spend}} of {{cap}} used" +thisPeriod = "This billing period" + [payment] autoClose = "This window will close automatically..." canCloseWindow = "You can now close this window." diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 22bdce81e..eaba23620 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [ "adminStorageSharing", "adminMcp", "help", + "payg", ] as const; // Derive the type from the array diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 6a99d61f3..55d73d7e0 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -20,6 +20,7 @@ export interface AppConfig { enableDesktopInstallSlide?: boolean; premiumEnabled?: boolean; premiumKey?: string; + paygEnabled?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 02d4688fb..7cbe522f5 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -13,6 +13,7 @@ import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap"; +import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; // Import global styles import "@app/styles/tailwind.css"; @@ -36,6 +37,7 @@ export default function App() { + } /> } /> diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 94c1ae824..8ed451ec4 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -18,11 +18,8 @@ import { CreditSummary, SubscriptionInfo, CreditCheckResult, - ApiCredits, } from "@app/types/credits"; -import apiClient, { - setGlobalCreditUpdateCallback, -} from "@app/services/apiClient"; +import { setGlobalCreditUpdateCallback } from "@app/services/apiClient"; import { synchronizeUserUpgrade } from "@app/services/userService"; import { syncOAuthAvatar, @@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [profilePictureMetadata, setProfilePictureMetadata] = useState(null); - const fetchCredits = useCallback( - async (sessionToUse?: Session | null) => { - const currentSession = sessionToUse ?? session; - - if (!currentSession?.user) { - console.debug("[Auth Debug] No user session, skipping credit fetch"); - setCreditBalance(null); - setCreditSummary(null); - setSubscription(null); - return; - } - - try { - console.debug( - "[Auth Debug] Fetching credits for user:", - currentSession.user.id, - ); - // Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must - // stay local rather than trigger the global login redirect. - const response = await apiClient.get("/api/v1/credits", { - suppressErrorToast: true, - skipAuthRedirect: true, - }); - const apiCredits = response.data; - - // Map server payload to app CreditSummary - const credits: CreditSummary = { - currentCredits: apiCredits.totalAvailableCredits, - maxCredits: - apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits, - creditsUsed: - apiCredits.weeklyCreditsAllocated - - apiCredits.weeklyCreditsRemaining + - (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining), - creditsRemaining: apiCredits.totalAvailableCredits, - resetDate: apiCredits.weeklyResetDate, - weeklyAllowance: apiCredits.weeklyCreditsAllocated, - }; - - setCreditSummary(credits); - setCreditBalance(credits.creditsRemaining); - - const subscriptionInfo: SubscriptionInfo = { - status: "active", - tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free", - creditsPerWeek: credits.weeklyAllowance, - maxCredits: credits.maxCredits, - }; - setSubscription(subscriptionInfo); - - console.debug("[Auth Debug] Credits fetched successfully:", credits); - } catch (error: unknown) { - console.debug("[Auth Debug] Failed to fetch credits:", error); - // Don't set error state for credit fetching failures to avoid disrupting auth flow - // Credits might not be available in all deployments - setCreditBalance(null); - setCreditSummary(null); - setSubscription(null); - } - }, - [session], - ); + // Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via + // useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing + // consumers of useAuth() that still destructure creditBalance / refreshCredits + // compile cleanly; values just stay null forever and refreshCredits is a noop. + // _ underscore on the param keeps the public signature stable for callers. + const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => { + /* legacy credit fetch removed — see comment above */ + }, []); const refreshCredits = useCallback(async () => { - await fetchCredits(); - }, [fetchCredits]); + /* legacy credit refresh removed — useWallet() replaces this */ + }, []); const fetchProStatus = useCallback( async (sessionToUse?: Session | null) => { diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx new file mode 100644 index 000000000..6c28156f3 --- /dev/null +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState, useMemo } from "react"; +import { Modal, Stack, Button, Text } from "@mantine/core"; +import PersonAddIcon from "@mui/icons-material/PersonAdd"; +import { useTranslation } from "react-i18next"; +import { withBasePath } from "@app/constants/app"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import type { PaygSignupRequiredDetail } from "@app/services/paygErrorInterceptor"; + +/** + * Bootstrap that listens for {@code payg:signupRequired} (dispatched by + * the {@code apiClient} response interceptor when an anonymous user hits + * a billable endpoint and the server returns {@code 401 SIGNUP_REQUIRED}) + * and opens a Mantine modal explaining the free 500-op/month allowance + * with a "Sign up free" CTA. + * + *

Why an event bus instead of direct render

+ * The {@code apiClient} module is created at app boot, outside the React + * tree, and can't import JSX. We bridge with a {@code CustomEvent}: the + * interceptor dispatches, this bootstrap (mounted near the app root) + * listens and renders. Same pattern as {@code TrialExpiredBootstrap}, + * just driven by a request-side trigger rather than auth state. + * + *

De-duping

+ * If the user fires multiple billable requests in quick succession (e.g. + * clicking a tool button twice), only one modal opens — the listener + * ignores the event when the modal is already visible. The modal closes + * on backdrop click or Escape; we don't gate it on a localStorage flag + * because this is a deterministic "you need an account" UI, not a one- + * time onboarding nudge. + */ +export default function SignupRequiredBootstrap() { + const { t } = useTranslation(); + const [opened, setOpened] = useState(false); + const [category, setCategory] = useState(null); + + useEffect(() => { + const handler = (ev: Event) => { + const detail = (ev as CustomEvent).detail; + // De-dupe: if the modal is already open, the existing copy wins. + // The user is already being prompted; piling another open call on + // top would just flicker the same content. + setOpened((wasOpen) => { + if (!wasOpen) { + setCategory(detail?.category ?? null); + } + return true; + }); + }; + window.addEventListener("payg:signupRequired", handler as EventListener); + return () => + window.removeEventListener( + "payg:signupRequired", + handler as EventListener, + ); + }, []); + + // Map the server's gate categories to user-facing nouns. The server + // returns the FeatureGate name (AI, AUTOMATION, API); the user has + // no idea what those are in raw form, so we pretty-print here. The + // fallback "this feature" keeps the modal sensible if the BE adds + // a category we don't know about. + const categoryNoun = useMemo(() => { + switch ((category ?? "").toUpperCase()) { + case "AI": + return t("payg.signupRequired.category.ai", "AI features"); + case "AUTOMATION": + return t("payg.signupRequired.category.automation", "automations"); + case "API": + return t("payg.signupRequired.category.api", "this tool"); + default: + return t("payg.signupRequired.category.default", "this feature"); + } + }, [category, t]); + + const handleSignUp = () => { + window.location.href = withBasePath("/signup"); + }; + + return ( + setOpened(false)} + withCloseButton + centered + size="md" + radius="lg" + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} + title={ + + {t("payg.signupRequired.title", "Sign up to use {{category}}", { + category: categoryNoun, + })} + + } + > + + + {t( + "payg.signupRequired.body", + "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.", + )} + + + {t( + "payg.signupRequired.subtext", + "Creating an account is free and takes a few seconds. No credit card required.", + )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 550da1dce..dec671fbc 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -61,6 +61,24 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { window.removeEventListener("appConfig:notice", handler as EventListener); }, []); + // Full-screen overlays that live inside our React tree (e.g. the PAYG + // UpgradeModal, portal'd to document.body) announce themselves here so we + // can hide — not unmount — while they're up. Unmounting would kill the + // overlay itself since it's our descendant; hiding keeps all section state + // (active tab, scroll, wallet data) intact for when the overlay closes. + const [overlayActive, setOverlayActive] = useState(false); + useEffect(() => { + const handler = (ev: Event) => { + const detail = (ev as CustomEvent).detail as + | { open?: boolean } + | undefined; + setOverlayActive(Boolean(detail?.open)); + }; + window.addEventListener("appConfig:overlay", handler as EventListener); + return () => + window.removeEventListener("appConfig:overlay", handler as EventListener); + }, []); + // When the modal opens to Plan, proactively refresh credits and log values useEffect(() => { if (!opened) return; @@ -106,7 +124,9 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []); - // Left navigation structure and icons + // Left navigation structure and icons. The Plan tab now internally branches + // free vs subscribed × leader vs member via useWallet(), so the modal no + // longer plumbs paygEnabled / isLeader through to the nav builder. const configNavSections = useMemo( () => createSaasConfigNavSections(Overview, openLogoutConfirm, { @@ -139,7 +159,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { opened={opened} onClose={onClose} title={null} - size={isMobile ? "100%" : 980} + size={isMobile ? "100%" : 1200} centered radius="lg" withCloseButton={false} @@ -147,6 +167,17 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { overlayProps={{ opacity: 0.35, blur: 2 }} padding={0} fullScreen={isMobile} + // Hidden (not closed) while a child overlay like the PAYG UpgradeModal + // is up — see the appConfig:overlay listener above. The focus trap and + // escape/outside-close must release too: the trap would steal focus + // from the Stripe card iframe, and Escape would close US underneath + // the overlay — unmounting the checkout mid-payment. + styles={{ + root: { display: overlayActive ? "none" : undefined }, + }} + trapFocus={!overlayActive} + closeOnEscape={!overlayActive} + closeOnClickOutside={!overlayActive} >
{/* Left navigation */} diff --git a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx b/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx index 2ac6318bb..f5bf270cc 100644 --- a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx +++ b/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx @@ -42,10 +42,7 @@ export function ManageBillingButton({ url: string; error?: string; }>("manage-billing", { - body: { - name: "Functions", - return_url: returnUrl, - }, + body: { return_url: returnUrl }, }); if (error) throw error; if (!data || "error" in data) diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.css b/frontend/editor/src/saas/components/shared/config/configSections/Payg.css new file mode 100644 index 000000000..8e1921d06 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.css @@ -0,0 +1,624 @@ +/* Pay-as-you-go settings screen — visual polish. + Uses the app's semantic theme tokens (theme.css) so it tracks light/dark. */ + +.payg { + --payg-accent: #0a8bff; + --payg-accent-soft: rgba(10, 139, 255, 0.1); + --payg-radius: 14px; + --payg-gap: 20px; + /* Cards sit ON the modal content bg; in light that's white-on-white so we + lean on border + shadow. Tokens are overridden per scheme below. */ + --payg-card-bg: var(--bg-surface); + --payg-card-border: var(--border-default); + --payg-inset-bg: var(--bg-raised); + --payg-divider: var(--border-subtle); +} + +/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain + cards vanish. Lift cards a shade above the modal and strengthen borders. */ +[data-mantine-color-scheme="dark"] .payg { + --payg-card-bg: #313842; + --payg-card-border: #3d444e; + --payg-inset-bg: #272c33; + --payg-accent-soft: rgba(10, 139, 255, 0.16); + --payg-divider: #3d444e; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ +.payg-header__title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text-primary); +} +.payg-header__subtitle { + margin-top: 4px; + font-size: 0.875rem; + color: var(--text-muted); +} +.payg-role-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} +.payg-role-pill[data-leader="true"] { + background: var(--payg-accent-soft); + color: var(--payg-accent); + border: 1px solid rgba(10, 139, 255, 0.25); +} +.payg-role-pill[data-leader="false"] { + background: var(--bg-muted); + color: var(--text-muted); + border: 1px solid var(--border-default); +} + +/* ── Plan header: free-vs-metered split (shared by free + subscribed) ──── */ +.payg-planhead { + padding: 14px 20px; + border-radius: var(--payg-radius); + background: var(--payg-card-bg); + border: 1px solid var(--payg-card-border); +} +.payg-planhead__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.payg-planhead__eyebrow { + font-size: 0.78rem; + color: var(--text-muted); +} +.payg-planhead__split { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); +} +.payg-planhead__col { + padding-right: 22px; +} +.payg-planhead__col--meter { + padding-right: 0; + padding-left: 22px; + border-left: 1px solid var(--payg-divider); +} +.payg-planhead__lbl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + margin-bottom: 8px; +} +.payg-planhead__lbl--free { + color: #10b981; +} +.payg-planhead__lbl--meter { + color: var(--payg-accent); +} +.payg-planhead__lbl-icon { + font-size: 1rem !important; +} +.payg-planhead__title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; + line-height: 1.25; +} +.payg-planhead__body { + margin: 5px 0 0; + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.5; +} +@media (max-width: 640px) { + .payg-planhead__split { + grid-template-columns: 1fr; + gap: 16px; + } + .payg-planhead__col { + padding-right: 0; + } + .payg-planhead__col--meter { + padding-left: 0; + padding-top: 16px; + border-left: none; + border-top: 1px solid var(--payg-divider); + } +} + +/* ── Generic card ───────────────────────────────────────────────────── */ +.payg-card { + background: var(--payg-card-bg); + border: 1px solid var(--payg-card-border); + border-radius: var(--payg-radius); + padding: 16px 20px; + box-shadow: var(--shadow-xs); +} +.payg-card__title { + font-size: 0.95rem; + font-weight: 650; + color: var(--text-primary); +} +.payg-card__subtitle { + font-size: 0.8125rem; + color: var(--text-muted); + margin-top: 2px; +} + +/* ── Hero usage panel ───────────────────────────────────────────────── */ +.payg-hero { + position: relative; + overflow: hidden; + border-radius: var(--payg-radius); + padding: 18px 22px; + border: 1px solid var(--payg-card-border); + background: var(--payg-card-bg); + box-shadow: var(--shadow-xs); +} +.payg-hero::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.9; +} +.payg-hero[data-state="FULL"]::before { + background: linear-gradient( + 135deg, + rgba(10, 139, 255, 0.12) 0%, + rgba(10, 139, 255, 0) 55% + ); +} +.payg-hero[data-state="WARNED"]::before { + background: linear-gradient( + 135deg, + rgba(234, 179, 8, 0.16) 0%, + rgba(234, 179, 8, 0) 55% + ); +} +.payg-hero[data-state="DEGRADED"]::before { + background: linear-gradient( + 135deg, + rgba(239, 68, 68, 0.16) 0%, + rgba(239, 68, 68, 0) 55% + ); +} +.payg-hero__inner { + position: relative; + z-index: 1; +} +.payg-hero__eyebrow { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} +.payg-hero__figure { + display: flex; + align-items: baseline; + gap: 10px; + margin-top: 6px; +} +.payg-hero__spend { + font-size: 2.35rem; + font-weight: 750; + line-height: 1; + letter-spacing: -0.03em; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.payg-hero__cap { + font-size: 1rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.payg-hero__meta { + margin-top: 14px; + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + font-size: 0.8125rem; + color: var(--text-secondary); +} +.payg-hero__meta-dot { + color: var(--border-strong); +} +.payg-hero__credit { + display: inline-flex; + align-items: center; + padding: 2px 9px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-green-700); + background: rgba(34, 197, 94, 0.14); +} +[data-mantine-color-scheme="dark"] .payg-hero__credit { + color: #4ade80; + background: rgba(34, 197, 94, 0.18); +} + +/* Make Mantine default-variant buttons read clearly on the lifted cards in + both themes (their stock border can wash out against --payg-card-bg). */ +.payg .mantine-Button-root[data-variant="default"] { + border-color: var(--payg-card-border); +} + +/* Dark mode: the stock default button is a flat slab that disappears into the + card. Lift it with a soft top-down gradient + brighter border, and warm the + hover so the buttons feel tactile against --payg-card-bg (#313842). Driven + through Mantine's --button-* vars. The same rule covers disabled buttons + (Cancel/Update cap before the form is dirty) so they read identically to the + always-enabled ones — Mantine only fades them via opacity. */ +[data-mantine-color-scheme="dark"] + .payg + .mantine-Button-root[data-variant="default"] { + --button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%); + --button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%); + --button-bd: 1px solid #4b5563; + --button-color: var(--mantine-color-white); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 1px 2px rgba(0, 0, 0, 0.25); +} +/* Mantine recolours disabled buttons to a dim grey via a hard-coded color/bg + on the disabled selector, overriding --button-color. Re-assert white + the + gradient so disabled buttons match the rest. */ +[data-mantine-color-scheme="dark"] + .payg + .mantine-Button-root[data-variant="default"]:disabled, +[data-mantine-color-scheme="dark"] + .payg + .mantine-Button-root[data-variant="default"][data-disabled] { + background: var(--button-bg); + color: var(--mantine-color-white); +} + +/* Status chip */ +.payg-status { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 13px; + border-radius: 999px; + font-size: 0.8125rem; + font-weight: 600; + white-space: nowrap; +} +.payg-status__dot { + width: 8px; + height: 8px; + border-radius: 999px; +} +.payg-status[data-state="FULL"] { + background: var(--color-green-100); + color: var(--color-green-700); +} +.payg-status[data-state="FULL"] .payg-status__dot { + background: var(--color-green-500); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} +.payg-status[data-state="WARNED"] { + background: var(--color-yellow-100); + color: var(--color-yellow-700); +} +.payg-status[data-state="WARNED"] .payg-status__dot { + background: var(--color-yellow-500); + box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2); +} +.payg-status[data-state="DEGRADED"] { + background: var(--color-red-100); + color: var(--color-red-700); +} +.payg-status[data-state="DEGRADED"] .payg-status__dot { + background: var(--color-red-500); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); +} + +/* Segmented usage bar */ +.payg-bar { + margin-top: 18px; + height: 10px; + border-radius: 999px; + background: var(--bg-muted); + overflow: hidden; + position: relative; +} +.payg-bar__fill { + height: 100%; + border-radius: 999px; + transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} +.payg-bar__fill[data-state="FULL"] { + background: linear-gradient(90deg, #0a8bff, #38bdf8); +} +.payg-bar__fill[data-state="WARNED"] { + background: linear-gradient(90deg, #f59e0b, #fbbf24); +} +.payg-bar__fill[data-state="DEGRADED"] { + background: linear-gradient(90deg, #dc2626, #f87171); +} + +/* ── "What counts as a document?" expandable help ───────────────────── */ +.payg-help { + margin-top: 12px; +} +.payg-help__toggle { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0; + border: none; + background: none; + cursor: pointer; + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary); +} +.payg-help__toggle:hover { + color: var(--text-primary); +} +.payg-help__chevron { + transition: transform 0.15s ease; +} +.payg-help__toggle[aria-expanded="true"] .payg-help__chevron { + transform: rotate(180deg); +} +.payg-help__panel { + margin-top: 10px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--payg-card-border); + background: var(--bg-muted); + font-size: 0.8125rem; + line-height: 1.45; + color: var(--text-secondary); +} +.payg-help__panel ul { + margin: 0; + padding-left: 18px; + display: grid; + gap: 6px; +} + +/* ── Cap preview strip ──────────────────────────────────────────────── */ +.payg-preview { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 16px; + border-radius: 10px; + background: var(--payg-accent-soft); + border: 1px solid rgba(10, 139, 255, 0.2); +} +.payg-preview__icon { + color: var(--payg-accent); + display: flex; +} +.payg-preview__main { + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 550; +} +.payg-preview__note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 1px; +} + +/* ── Gates grid ─────────────────────────────────────────────────────── */ +.payg-gates { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-top: 4px; +} +@media (max-width: 720px) { + .payg-gates { + grid-template-columns: 1fr; + } +} +.payg-gate { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 15px; + border-radius: 11px; + border: 1px solid var(--payg-card-border); + background: var(--payg-inset-bg); +} +.payg-gate[data-enabled="false"] { + border-style: dashed; + opacity: 0.85; +} +.payg-gate__chip { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9px; + flex-shrink: 0; +} +.payg-gate[data-enabled="true"] .payg-gate__chip { + background: rgba(34, 197, 94, 0.16); + color: #22c55e; +} +.payg-gate[data-enabled="false"] .payg-gate__chip { + background: rgba(239, 68, 68, 0.16); + color: #f87171; +} +.payg-gate__label { + font-size: 0.8125rem; + line-height: 1.35; + color: var(--text-primary); + flex: 1; +} +.payg-gate__tag { + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + flex-shrink: 0; + white-space: nowrap; +} +.payg-gate__tag[data-variant="on"] { + color: var(--text-muted); + background: var(--bg-muted); +} +.payg-gate__tag[data-variant="pause"] { + color: #dc2626; + background: rgba(239, 68, 68, 0.12); +} +[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] { + color: #f87171; + background: rgba(239, 68, 68, 0.18); +} + +/* ── Member rows ────────────────────────────────────────────────────── */ +.payg-member { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 4px; + border-bottom: 1px solid var(--payg-divider); +} +.payg-member:last-child { + border-bottom: none; +} +.payg-member__avatar { + width: 34px; + height: 34px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8125rem; + font-weight: 650; + color: #fff; + flex-shrink: 0; +} +.payg-member__name { + font-size: 0.875rem; + font-weight: 550; + color: var(--text-primary); +} +.payg-member__email { + font-size: 0.75rem; + color: var(--text-muted); +} +.payg-member__usage { + text-align: right; + min-width: 120px; +} +.payg-member__usage-num { + font-size: 0.8125rem; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.payg-member__minibar { + height: 5px; + width: 90px; + border-radius: 999px; + background: var(--payg-inset-bg); + overflow: hidden; + margin-top: 5px; + margin-left: auto; +} +.payg-member__minibar-fill { + height: 100%; + border-radius: 999px; + background: var(--payg-accent); +} + +/* ── Activity feed ──────────────────────────────────────────────────── */ +.payg-activity-row { + display: flex; + align-items: center; + gap: 14px; + padding: 11px 4px; + border-bottom: 1px solid var(--payg-divider); +} +.payg-activity-row:last-child { + border-bottom: none; +} +.payg-activity__dot { + width: 9px; + height: 9px; + border-radius: 999px; + flex-shrink: 0; +} +.payg-activity__dot[data-kind="ai"] { + background: var(--category-color-formatting); /* purple */ +} +.payg-activity__dot[data-kind="automation"] { + background: var(--category-color-automation); /* pink */ +} +.payg-activity__label { + font-size: 0.8438rem; + color: var(--text-primary); + flex: 1; +} +.payg-activity__ts { + font-size: 0.75rem; + color: var(--text-muted); +} +.payg-activity__kind { + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + background: var(--bg-muted); + padding: 2px 8px; + border-radius: 999px; +} +.payg-activity__units { + font-size: 0.8125rem; + font-weight: 650; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + min-width: 58px; + text-align: right; +} + +/* ── Stripe CTA card ────────────────────────────────────────────────── */ +.payg-stripe { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 24px; + border-radius: var(--payg-radius); + border: 1px solid rgba(99, 91, 255, 0.28); + background: linear-gradient( + 135deg, + rgba(99, 91, 255, 0.1) 0%, + rgba(99, 91, 255, 0.02) 100% + ); +} +.payg-stripe__title { + font-size: 0.9375rem; + font-weight: 650; + color: var(--text-primary); +} +.payg-stripe__subtitle { + font-size: 0.8125rem; + color: var(--text-muted); + margin-top: 2px; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx new file mode 100644 index 000000000..b431c1386 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx @@ -0,0 +1,788 @@ +/** + * Pay-as-you-go billing & usage section — the SUBSCRIBED leader/member views. + * + * All data comes from the real {@code Wallet} snapshot ({@code GET + * /api/v1/payg/wallet} via {@link useWallet}); there is no mock fallback. What + * the wallet doesn't provide, the UI doesn't show: + * + * - spend + cap render in the units/USD the backend actually returns + * ({@code spendUnitsThisPeriod}, {@code capUsd}, {@code noCap}) + * - the per-category breakdown comes from {@code categoryBreakdown} + * (wallet_category_summary view) + * - money-equivalent display and the units↔money cap preview need + * stripe.prices via Sync Engine (design §13 / PR-C2) and are deliberately + * absent until that ships + * - the activity feed renders {@code wallet.recent}, which is {@code []} in + * V1 — so it shows a real empty state, not fabricated rows + */ +import React, { useState } from "react"; +import { Button, Group, Stack, Text } from "@mantine/core"; +import { useRenderCount } from "@app/hooks/useRenderCount"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import LockIcon from "@mui/icons-material/LockOutlined"; +import CheckIcon from "@mui/icons-material/CheckRounded"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutlineRounded"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMoreRounded"; +import BoltIcon from "@mui/icons-material/BoltRounded"; +import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded"; +import { alert as showToast } from "@app/components/toast"; +// Relative (not @app/*) so the co-located CSS + sibling component resolve directly. +// eslint-disable-next-line no-restricted-imports +import "./Payg.css"; +// eslint-disable-next-line no-restricted-imports +import SpendCapControl from "./SpendCapControl"; +import { useTranslation } from "react-i18next"; +import type { Wallet } from "@app/hooks/useWallet"; + +// ─── Types ──────────────────────────────────────────────────────────────── + +type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE"; + +interface PaygProps { + role: "LEADER" | "MEMBER"; + /** Real wallet snapshot from {@code useWallet}. Single source of truth. */ + wallet: Wallet; + /** + * Persist a cap change. Provided by {@code Plan} → {@code useWallet} for the + * leader view; absent on the member view (read-only). + */ + onSaveCap?: (capUsd: number | null) => Promise | void; + /** + * Open the Stripe Customer Portal. When omitted the Stripe card is hidden. + * On error the implementation shows a friendly toast and resolves — callers + * don't need to wrap in try/catch. + */ + onOpenPortal?: () => Promise; +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +// Stable-ish avatar colour from a string. +const AVATAR_COLORS = [ + "#0a8bff", + "#8b5cf6", + "#ec4899", + "#10b981", + "#f59e0b", + "#06b6d4", +]; +function avatarColor(seed: string): string { + let h = 0; + for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0; + return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]; +} + +function gateLabel( + g: Gate, + t: (k: string, fallback: string) => string, +): string { + switch (g) { + case "OFFSITE_PROCESSING": + return t( + "payg.gates.offsite", + "Server tools (compress, OCR, convert, watermark…)", + ); + case "AUTOMATION": + return t("payg.gates.automation", "Automations & pipelines"); + case "AI_SUPPORT": + return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)"); + case "CLIENT_SIDE": + return t( + "payg.gates.client", + "Browser-only tools (viewer, page editor, file management)", + ); + } +} + +// ─── "What counts as a document?" help ────────────────────────────────────── + +/** + * Expandable explainer for the billing unit. Shared by the subscribed hero + * here and the free-tier hero in {@code PaygFree.tsx}. The bullets state the + * real charge mechanics (DefaultDocumentClassifier + JobChargeService) + * without hardcoding the policy-tunable thresholds: each non-empty file is + * at least one document; page count / file size can make it more; chained + * steps on the same file join the open process instead of re-charging; a + * first-step failure writes a compensating refund. + */ +export function DocHelp() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+
    +
  • + {t( + "payg.docHelp.billable", + "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.", + )} +
  • +
  • + {t( + "payg.docHelp.perFile", + "Each file you process counts as one PDF. Very long or very large files can count as more than one.", + )} +
  • +
  • + {t( + "payg.docHelp.chains", + "Running the same file through several steps of one automation counts it once, not once per step.", + )} +
  • +
  • + {t( + "payg.docHelp.refunds", + "If a job fails on its first step, the PDF is credited back automatically.", + )} +
  • +
+
+ )} +
+ ); +} + +// ─── Hero usage panel ─────────────────────────────────────────────────────── + +/** + * Format minor units of an ISO currency for display ("$2.24", "£0.40"). Only + * called when the backend resolved the rate — currency is always present + * alongside a non-null money amount. + */ +function formatMinor(minor: number, currency: string | null): string { + const code = (currency ?? "usd").toUpperCase(); + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: code, + }).format(minor / 100); + } catch { + return `${(minor / 100).toFixed(2)} ${code}`; + } +} + +/** Currency symbol for compact inline use; falls back to the ISO code. */ +function currencySymbol(currency: string | null): string { + switch ((currency ?? "").toLowerCase()) { + case "usd": + return "$"; + case "eur": + return "€"; + case "gbp": + return "£"; + default: + return currency ? currency.toUpperCase() + " " : "$"; + } +} + +/** + * Usage hero — gradient panel with the allowance bar. Every number is real: + * {@code billableUsed} is the ledger's period sum over the team's actual + * billing window (the Stripe subscription period); {@code billableLimit} is + * the backend-derived document ceiling (free allowance + what the money cap + * buys at the subscription Price's per-document rate, null when uncapped); + * {@code estimatedBillMinor} is spend beyond the allowance at that rate. + * Fields the backend couldn't resolve are null and simply not rendered. + */ +function UsageHero({ wallet }: { wallet: Wallet }) { + const { t } = useTranslation(); + + const periodEnd = new Date(wallet.billingPeriodEnd); + const daysLeft = Math.max( + 0, + Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000), + ); + const hasCap = !wallet.noCap && wallet.capUsd != null; + const breakdown = wallet.categoryBreakdown; + + const limit = wallet.billableLimit; + const hasLimit = limit != null && limit > 0; + const pct = hasLimit + ? Math.min(100, (wallet.billableUsed / limit) * 100) + : 0; + const state = hasLimit + ? pct >= 100 + ? "DEGRADED" + : pct >= 80 + ? "WARNED" + : "FULL" + : "FULL"; + const stateLabel = { + FULL: t("payg.state.full", "Healthy"), + WARNED: t("payg.state.warned", "Approaching cap"), + DEGRADED: t("payg.state.degraded", "Cap reached"), + }[state]; + + return ( +
+
+ +
+
+ {t("payg.usage.thisPeriod", "This billing period")} +
+
+ + {wallet.billableUsed.toLocaleString()} + + + {hasLimit + ? t( + "payg.usage.ofLimitProcessed", + "/ {{limit}} PDFs processed", + { limit: limit.toLocaleString() }, + ) + : t("payg.usage.processed", "PDFs processed")} + +
+
+
+ + {stateLabel} +
+
+ + {hasLimit && ( +
+
+
+ )} + +
+ + {t("payg.usage.firstFree", "First {{free}} free", { + free: wallet.freeAllowance.toLocaleString(), + })} + + {wallet.estimatedBillMinor != null && ( + <> + + + {t("payg.usage.estBill", "≈ {{amount}} so far this period", { + amount: formatMinor( + wallet.estimatedBillMinor, + wallet.currency, + ), + })} + + + )} + + + {hasCap + ? t("payg.usage.capLine", "{{cap}}/mo cap", { + cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`, + }) + : t("payg.usage.noCap", "No monthly cap")} + + + + {daysLeft === 1 + ? t("payg.usage.resetsTomorrow", "Resets tomorrow") + : t("payg.usage.resetsIn", "Resets in {{days}} days", { + days: daysLeft, + })} + +
+ +
+ + {t( + "payg.usage.breakdown", + "AI {{ai}} • Automation {{automation}} • API {{api}}", + { + ai: breakdown.ai.toLocaleString(), + automation: breakdown.automation.toLocaleString(), + api: breakdown.api.toLocaleString(), + }, + )} + +
+ + +
+
+ ); +} + +// ─── Cap editor ───────────────────────────────────────────────────────────── + +interface CapEditorProps { + /** Current cap in major currency units; null = no cap set. */ + capUsd: number | null; + /** True when the leader explicitly disabled the cap. */ + noCap: boolean; + /** Per-document rate in minor units; null when unknown — preview hides. */ + pricePerDocMinor: number | null; + /** Currency of the rate; pairs with {@link CapEditorProps#pricePerDocMinor}. */ + currency: string | null; + /** + * Persist the cap change. Receives whole major units (matches the backend's + * {@code PATCH /api/v1/payg/cap} body) or null for no-cap. + */ + onSaveCap?: (capUsd: number | null) => Promise | void; +} + +/** + * Single-row cap editor: the shared {@link SpendCapControl} (preset chips + + * inline custom-entry pill + no-cap + inline Save) over a live "≈ N paid + * PDFs/month" estimate, wrapped in the plan-page card chrome + the cap-reached + * disclosure. Save-only — the working value is local, so abandoning the card + * abandons the edit. The very same control drives the upgrade checkout flow. + */ +function CapEditor({ + capUsd, + noCap, + pricePerDocMinor, + currency, + onSaveCap, +}: CapEditorProps) { + const { t } = useTranslation(); + const savedCap = noCap || capUsd == null ? null : capUsd; + const [working, setWorking] = useState(savedCap); + + return ( +
+ +
+
+ {t("payg.cap.title", "Monthly spending cap")} +
+
+ {t( + "payg.cap.subtitle", + "The most your team can spend per month. Billable processing pauses at the cap and resumes next period.", + )} +
+
+ + + + +
+
+ ); +} + +function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) { + const { t } = useTranslation(); + const hasCap = !noCap && capUsd != null; + return ( +
+ +
+ {t("payg.cap.title", "Monthly spending cap")} +
+ + + {hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")} + + {hasCap && ( + {t("payg.cap.perMonth", "/ month")} + )} + +
+
+
+ {t( + "payg.member.askLeader", + "Only your team owner can change the cap.", + )} +
+
+
+ + +
+
+ ); +} + +// ─── Gates ──────────────────────────────────────────────────────────────── + +// What still works once the cap is hit. Everyday tools keep running; only AI +// and automation/pipelines pause until the cap resets or is raised. +const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [ + { gate: "CLIENT_SIDE", staysAtCap: true }, + { gate: "OFFSITE_PROCESSING", staysAtCap: true }, + { gate: "AUTOMATION", staysAtCap: false }, + { gate: "AI_SUPPORT", staysAtCap: false }, +]; + +/** + * Folded-in "what happens at the cap" — previously its own GatesCard, now a + * collapsed disclosure rendered inside the cap card(s) to save vertical space. + * Mirrors the {@link DocHelp} toggle pattern; default-collapsed so it costs no + * height until the user opens it. + */ +function CapReachedHelp() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+
+ {GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => ( +
+ + {staysAtCap ? ( + + ) : ( + + )} + + {gateLabel(gate, t)} + {!staysAtCap && ( + + {t("payg.gates.pauses", "pauses at cap")} + + )} +
+ ))} +
+
+ )} +
+ ); +} + +// ─── Per-member usage ──────────────────────────────────────────────────────── + +/** + * Leader-only roster of each teammate's billable usage this period. Display-only — per-member + * sub-cap enforcement isn't shipped (see the follow-ups note), so there's no cap control here. + */ +function MemberUsage({ members }: { members: Wallet["members"] }) { + const { t } = useTranslation(); + return ( +
+ +
+
+ {t("payg.members.title", "Team member usage")} +
+
+ {t( + "payg.members.subtitle", + "Billable PDFs each teammate has processed this period.", + )} +
+
+
+ {members.map((m) => ( +
+ + {m.name.charAt(0).toUpperCase()} + +
+
{m.name}
+
{m.email}
+
+
+
+ {m.spendUnits.toLocaleString()}{" "} + + {t("payg.members.docs", "PDFs")} + +
+
+
+ ))} +
+
+
+ ); +} + +// ─── Activity feed ────────────────────────────────────────────────────────── + +/** + * Feature flag — the activity feed is hidden until the meter-event surface is + * built and polished (Wave 2). The backend returns {@code []} today, so an + * unpolished "No billable activity yet" card adds nothing. Flip to {@code true} + * once {@code wallet.recent} carries real rows. Kept as a flag (not deleted) so + * the renderer below stays wired and ready. + */ +const SHOW_ACTIVITY_FEED = false; + +/** + * Renders {@code wallet.recent} — the backend returns {@code []} in V1 (the + * meter-event surface ships in Wave 2), so today this shows a real empty + * state. The row renderer is ready for when the rows arrive; fields are read + * defensively because the activity-row shape isn't finalised yet (the Wallet + * type carries {@code Record} for the same reason). + */ +function ActivityFeed({ recent }: { recent: Wallet["recent"] }) { + const { t } = useTranslation(); + return ( +
+ +
+
+ {t("payg.activity.title", "Recent billable activity")} +
+
+ {t( + "payg.activity.subtitle", + "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.", + )} +
+
+ {recent.length === 0 ? ( + + {t( + "payg.activity.empty", + "No billable activity yet this period.", + )} + + ) : ( +
+ {recent.map((r, i) => ( +
+ +
+
+ {String(r.label ?? "")} +
+
{String(r.ts ?? "")}
+
+ + {String(r.kind ?? "")} + + + {String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")} + +
+ ))} +
+ )} +
+
+ ); +} + +// ─── Stripe CTA ────────────────────────────────────────────────────────────── + +function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + setLoading(true); + try { + await onOpenPortal(); + } catch (e: unknown) { + // 503 = Supabase edge fn isn't configured (local dev without + // PORTAL_NOT_CONFIGURED env). 404 = no Stripe customer yet (e.g. the + // team was force-subscribed via dev hooks). Both are user-actionable + // in roughly the same way ("try again later or contact support") so + // we don't bother branching the copy. + console.warn("[Payg] portal session failed", e); + showToast({ + alertType: "warning", + title: t( + "payg.stripe.toast.unavailable.title", + "Billing portal unavailable", + ), + body: t( + "payg.stripe.toast.unavailable.body", + "Billing portal isn't available right now. Try again in a moment.", + ), + location: "bottom-right", + }); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ {t("payg.stripe.title", "Manage billing in Stripe")} +
+
+ {t( + "payg.stripe.subtitle", + "Receipts, invoices, payment method, billing currency.", + )} +
+
+ +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────── + +const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) => { + useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember"); + const { t } = useTranslation(); + const isLeader = role === "LEADER"; + + const fmt = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { + day: "numeric", + month: "short", + }); + + return ( +
+ + {/* The modal chrome already renders the section title ("Billing & + usage"), so we lead with the descriptive subtitle + role pill. */} +
+
+ + {t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", { + start: fmt(wallet.billingPeriodStart), + end: fmt(wallet.billingPeriodEnd), + })} + + + {isLeader + ? t("payg.role.leader", "Team owner") + : t("payg.role.member", "Member")} + +
+ +
+
+
+ + {t("payg.header.freeLabel", "Always free")} +
+

+ {t("payg.header.freeTitle", "Unlimited PDF editing")} +

+

+ {t( + "payg.header.freeBody", + "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.", + )} +

+
+ +
+
+ + {t("payg.header.meterLabel", "Metered")} +
+

+ {t("payg.header.meterTitle", "Automation · AI · API")} +

+

+ {t( + "payg.header.meterBody", + "{{limit}} free PDFs to start, then billed per PDF up to your cap.", + { limit: wallet.freeAllowance.toLocaleString() }, + )} +

+
+
+
+ + + + {isLeader ? ( + + ) : ( + + )} + + {isLeader && wallet.members.length > 0 && ( + + )} + + {SHOW_ACTIVITY_FEED && } + + {isLeader && onOpenPortal && ( + + )} +
+
+ ); +}; + +export default Payg; + +// Convenience exports for the config nav to render either variant directly. +export interface PaygLeaderProps { + /** See {@link PaygProps#wallet}. */ + wallet: Wallet; + /** See {@link PaygProps#onSaveCap}. */ + onSaveCap?: (capUsd: number | null) => Promise | void; + /** See {@link PaygProps#onOpenPortal}. */ + onOpenPortal?: () => Promise; +} +export const PaygLeader: React.FC = ({ + wallet, + onSaveCap, + onOpenPortal, +}) => ( + +); +export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => ( + +); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css new file mode 100644 index 000000000..20534849d --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css @@ -0,0 +1,322 @@ +/* Styles for the free-tier Plan views (PaygFreeLeader + PaygFreeMember). + Extends Payg.css — the hero + bar + status pill reuse those classes so the + visual reads as a continuation of the Processor dashboard. New classes here + are prefixed `paygf-`. */ + +.payg-hero__head-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: nowrap; +} + +/* ── Editor plan card (always-free tools only, no billing window) ─────── */ +/* Reuses .payg-planhead chrome; the eyebrow sits in the flex top row so its + default 8px bottom margin would misalign it against the role pill. */ +.paygf-editorcard__eyebrow { + margin-bottom: 0; +} + +/* ── Processor plan card: two-column (pitch + benefits | meter + CTA) ──── */ +.paygf-proc { + gap: 14px; +} +.paygf-proc__eyebrow { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--payg-accent); +} +.paygf-proc__split { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 20px; +} +.paygf-proc__pitch { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} +.paygf-proc__pitch .paygf-cta__subtitle { + margin-top: 0; +} +/* Force the benefits into a single column inside the narrower left column. */ +.paygf-proc__benefits { + grid-template-columns: 1fr; +} +.paygf-proc__aside { + display: flex; + flex-direction: column; + gap: 12px; + padding-left: 20px; + border-left: 1px solid var(--payg-divider); +} +.paygf-proc__cta { + width: 100%; + text-align: center; +} +.paygf-proc__reassure { + text-align: center; +} +.paygf-proc__membernote { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.8rem; + line-height: 1.45; + color: var(--text-muted); +} +.paygf-proc__membernote-icon { + flex-shrink: 0; + color: var(--text-muted); + font-size: 1.1rem !important; + margin-top: 1px; +} +@media (max-width: 640px) { + .paygf-proc__split { + grid-template-columns: 1fr; + } + .paygf-proc__aside { + padding-left: 0; + padding-top: 16px; + border-left: none; + border-top: 1px solid var(--payg-divider); + } +} + +/* ── Compact one-time free meter (inside the Processor aside) ─────────── */ +.paygf-meter { + padding: 14px 16px; + border-radius: 11px; + background: var(--payg-inset-bg); + border: 1px solid var(--payg-card-border); +} +.paygf-meter__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.paygf-meter__figure { + display: flex; + align-items: baseline; + gap: 7px; +} +.paygf-meter__num { + font-size: 1.7rem; + font-weight: 750; + line-height: 1; + letter-spacing: -0.02em; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.paygf-meter__cap { + font-size: 0.85rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.paygf-meter .payg-bar { + margin-top: 11px; +} +.paygf-meter__meta { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + font-size: 0.78rem; + color: var(--text-secondary); +} + +/* ── Leader CTA card ──────────────────────────────────────────────────── */ + +.paygf-cta { + padding: 18px 22px; + border-radius: var(--payg-radius); + background: var(--payg-card-bg); + /* Gradient border using padding-box / border-box trick so the inner bg + stays solid + the outline gets the brand gradient. */ + border: 1.5px solid transparent; + background: + linear-gradient(var(--payg-card-bg), var(--payg-card-bg)) padding-box, + linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%) border-box; + box-shadow: 0 10px 28px -18px rgba(10, 139, 255, 0.4); + display: flex; + flex-direction: column; + gap: 16px; +} + +.paygf-cta__heading-row { + display: flex; + gap: 14px; + align-items: center; +} +.paygf-cta__icon { + flex-shrink: 0; + padding: 10px; + border-radius: 12px; + background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%); + color: white !important; + font-size: 1.7rem !important; +} +.paygf-cta__heading-text { + flex: 1 1 auto; +} +.paygf-cta__title { + margin: 0; + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; + line-height: 1.25; +} +.paygf-cta__subtitle { + margin: 4px 0 0; + font-size: 0.9rem; + color: var(--text-muted); + line-height: 1.5; +} + +.paygf-cta__benefits { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px 16px; +} +.paygf-cta__benefits li { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.85rem; + color: var(--text-primary); + line-height: 1.45; +} +.paygf-cta__check { + flex-shrink: 0; + color: var(--payg-accent); + margin-top: 2px; +} + +/* Footer row: button on the left, reassurance text on the right. + Right-side text wraps to second line below button on narrow viewports. */ +.paygf-cta__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + padding-top: 4px; + border-top: 1px solid var(--payg-divider); + padding-top: 16px; +} +.paygf-cta__button { + padding: 13px 22px; + border: none; + border-radius: 11px; + background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%); + color: white; + font-weight: 600; + font-size: 0.95rem; + font-family: inherit; + cursor: pointer; + transition: transform 120ms ease, box-shadow 120ms ease; + white-space: nowrap; +} +.paygf-cta__button:hover { + transform: translateY(-1px); + box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55); +} +.paygf-cta__reassurance { + margin: 0; + font-size: 0.78rem; + color: var(--text-muted); + text-align: right; +} + +/* ── Member ask-the-owner note ───────────────────────────────────────── */ + +.paygf-member-note { + padding: 18px 22px; + border-radius: var(--payg-radius); + background: var(--payg-inset-bg); + border: 1px solid var(--payg-card-border); + display: flex; + gap: 14px; + align-items: flex-start; +} +.paygf-member-note__icon { + flex-shrink: 0; + color: var(--text-muted); + padding: 8px; + background: var(--payg-card-bg); + border-radius: 10px; + font-size: 1.6rem !important; +} +.paygf-member-note__title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.005em; + line-height: 1.3; +} +.paygf-member-note__body { + margin: 6px 0 0; + font-size: 0.875rem; + color: var(--text-muted); + line-height: 1.5; +} + +/* ── Explainer (free vs metered) — shared by leader + member ──────────── */ + +.paygf-explainer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: var(--payg-gap); +} +@media (max-width: 640px) { + .paygf-explainer { + grid-template-columns: 1fr; + } +} +.paygf-explainer__col { + padding: 18px 20px; + border-radius: 12px; + background: var(--payg-card-bg); + border: 1px solid var(--payg-card-border); +} +.paygf-explainer__label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 10px; +} +.paygf-explainer__icon { + font-size: 1rem !important; +} +.paygf-explainer__icon--free { + color: #10b981; +} +.paygf-explainer__icon--paid { + color: var(--payg-accent); +} +.paygf-explainer__text { + margin: 0; + font-size: 0.85rem; + color: var(--text-primary); + line-height: 1.55; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx new file mode 100644 index 000000000..2c4a58cd1 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -0,0 +1,374 @@ +/** + * Free-tier Plan views (leader + member) shown before the team subscribes to + * Processor. Mirrors the visual language of {@code Payg.tsx} so the upgrade + * feels like enabling a switch, not visiting a new product. + * + *

Free tier model (set 2026-06): users only pay for + * automation, AI, and API operations. Manual tools + * — viewing, editing, signing, merging, splitting, conversion, manual OCR, + * watermarks, compression — are unmetered, no matter where they're triggered + * from. The distinction is the type of work (manual tool vs + * automation / AI / API), not where the click happens, because automation and + * AI also have UI surfaces. The one-time free grant (default 500) applies + * only to the three billable categories — it is a lifetime allowance, + * not a monthly one, and a team keeps any unused portion after subscribing. + * + *

Layout: a slim Editor plan card (always-free tools only — no dates, + * no metered split) on top, then a single Processor plan card that + * two-columns the upgrade pitch + benefits (left) against the one-time free + * meter stacked over the call-to-action (right). + * + *

Two variants: + * - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal. + * - {@link PaygFreeMember} — read-only; the CTA is replaced with an + * ask-the-owner note. + */ +import React, { useMemo, useState } from "react"; +import { Stack } from "@mantine/core"; +import BoltIcon from "@mui/icons-material/BoltRounded"; +import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded"; +import CheckIcon from "@mui/icons-material/CheckRounded"; +import LockIcon from "@mui/icons-material/LockOutlined"; +import { useTranslation } from "react-i18next"; +import { useRenderCount } from "@app/hooks/useRenderCount"; +import { useWallet } from "@app/hooks/useWallet"; +// eslint-disable-next-line no-restricted-imports +import "./Payg.css"; +// eslint-disable-next-line no-restricted-imports +import "./PaygFree.css"; +// eslint-disable-next-line no-restricted-imports +import UpgradeModal from "./UpgradeModal"; +// eslint-disable-next-line no-restricted-imports +import { DocHelp } from "./Payg"; + +// ─── Shared free-tier snapshot ──────────────────────────── + +interface FreeSnapshot { + /** One-time free documents used so far (grant − remaining). */ + billableUsed: number; + /** + * The team's one-time free grant size in documents. Real value from the + * wallet endpoint (pricing_policy.free_tier_units); 500 below is only the + * pre-load placeholder for the first paint. + */ + billableLimit: number; +} + +/** + * Read free-tier snapshot from the real {@link useWallet} hook. Falls back to + * a zeroed view if the wallet hasn't loaded yet — this only happens briefly on + * first paint; once the snapshot arrives the component re-renders with real + * numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked + * into the rendered UI and made the page look like nothing was wired up. + */ +function useFreeSnapshot(): FreeSnapshot { + const { wallet } = useWallet(); + return useMemo(() => { + if (wallet) { + return { + // Used = grant − remaining, derived straight from the one-time grant so + // the free view never depends on the per-state meaning of billableUsed. + billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining), + // The free view's ceiling IS the one-time grant size. + billableLimit: wallet.freeAllowance, + }; + } + return { billableUsed: 0, billableLimit: 500 }; + }, [wallet]); +} + +type MeterState = "FULL" | "WARNED" | "DEGRADED"; + +/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */ +function meterState(used: number, limit: number): { state: MeterState; pct: number } { + const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; + const state: MeterState = + pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; + return { state, pct }; +} + +// ─── Editor plan card (always-free tools only) ──────────────────────────── + +interface EditorPlanCardProps { + /** Role pill text on the right. */ + pill: string; + /** LEADER pill colour treatment. */ + leader?: boolean; +} + +/** + * The top card: the free Editor plan. Manual tools only, no billing window — + * the one-time grant lives in the Processor card below, so there's no period + * to show here. + */ +function EditorPlanCard({ pill, leader }: EditorPlanCardProps) { + const { t } = useTranslation(); + return ( +

+
+ + + {t("payg.free.editor.eyebrow", "Editor plan · Always free")} + + + {pill} + +
+

+ {t("payg.free.header.freeTitle", "Unlimited PDF editing")} +

+

+ {t( + "payg.free.header.freeBody", + "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.", + )} +

+
+ ); +} + +// ─── Compact one-time free meter (right column of the Processor card) ────── + +function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { + const { t } = useTranslation(); + const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); + const stateLabel = + state === "DEGRADED" + ? t("payg.free.state.limitReached", "Limit reached") + : state === "WARNED" + ? t("payg.free.state.approachingLimit", "Approaching limit") + : t("payg.free.state.plentyLeft", "Plenty left"); + + return ( +
+
+
+ + {snap.billableUsed.toLocaleString()} + + + {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { + limit: snap.billableLimit.toLocaleString(), + })} + +
+ + + {stateLabel} + +
+ +
+
+
+ +
+ + {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} + + + {t("payg.free.hero.neverResets", "One-time — never resets")} +
+
+ ); +} + +// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ────── + +interface ProcessorCardProps { + snap: FreeSnapshot; + /** Leaders get the live CTA; members get the ask-owner note. */ + isLeader: boolean; + /** Opens the upgrade modal — leader only. */ + onTurnOn?: () => void; +} + +function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) { + const { t } = useTranslation(); + return ( +
+ + + {t("payg.free.proc.eyebrow", "Processor plan · metered")} + + +
+
+

+ {t("payg.free.cta.title", "Turn on the Processor plan")} +

+

+ {t( + "payg.free.cta.subtitle", + "Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.", + { limit: snap.billableLimit.toLocaleString() }, + )} +

+ +
    +
  • + + + + {t("payg.free.cta.benefit1Title", "Automation pipelines")} + + {" — "} + {t( + "payg.free.cta.benefit1Body", + "chain tools, schedule runs, batch process", + )} + +
  • +
  • + + + {t("payg.free.cta.benefit2Title", "AI tools")} + {" — "} + {t( + "payg.free.cta.benefit2Body", + "summarise, classify, redact, AI-OCR", + )} + +
  • +
  • + + + {t("payg.free.cta.benefit3Title", "API access")} + {" — "} + {t( + "payg.free.cta.benefit3Body", + "call any Stirling endpoint programmatically", + )} + +
  • +
+ + +
+ +
+ + {isLeader ? ( + <> + + + {t( + "payg.free.cta.reassurance", + "No minimum · Set a $0 cap to test · Cancel anytime", + )} + + + ) : ( +
+ + + {t( + "payg.free.member.ownerOnly", + "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like.", + )} + +
+ )} +
+
+
+ ); +} + +// ─── Free LEADER ────────────────────────────────────────────────────────── + +export interface PaygFreeLeaderProps { + /** + * Called when the user finishes the {@link UpgradeModal} checkout flow. + * Plumbed up to {@code PlanSection} so the page can flip to the subscribed + * view immediately. When undefined we fall back to a demo {@code alert} so + * the dev preview route still works in isolation. + */ + onUpgraded?: (result: { capUsd: number | null }) => void; +} + +function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) { + useRenderCount("PaygFreeLeader"); + const { t } = useTranslation(); + const snap = useFreeSnapshot(); + const { wallet } = useWallet(); + const [upgradeOpen, setUpgradeOpen] = useState(false); + + return ( +
+ + + setUpgradeOpen(true)} + /> + + + {wallet?.teamId != null && ( + setUpgradeOpen(false)} + onComplete={({ capUsd }) => { + setUpgradeOpen(false); + if (onUpgraded) { + onUpgraded({ capUsd }); + } else { + // Standalone fallback (dev preview route renders without a parent + // handler). Real flow always passes onUpgraded via PlanSection. + alert( + `Demo: subscription complete. Cap = ${ + capUsd === null ? "no cap" : `$${capUsd}/mo` + }.`, + ); + } + }} + /> + )} +
+ ); +} + +// ─── Free MEMBER ────────────────────────────────────────────────────────── + +function PaygFreeMemberInner() { + useRenderCount("PaygFreeMember"); + const { t } = useTranslation(); + const snap = useFreeSnapshot(); + + return ( +
+ + + + +
+ ); +} + +// React.memo so Plan re-rendering on loading/error toggles doesn't cascade +// down to these leaves. Plan passes a stable onUpgraded callback (hoisted in +// Plan.tsx) so the prop identity stays stable across wallet refetches. +export const PaygFreeLeader = React.memo(PaygFreeLeaderInner); +export const PaygFreeMember = React.memo(PaygFreeMemberInner); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx index 8571c5db4..eaeccbc72 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx @@ -1,245 +1,91 @@ -import React, { lazy, Suspense, useState, useCallback, useEffect } from "react"; -import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core"; -import { usePlans, PlanTier } from "@app/hooks/usePlans"; -import type { - PurchaseType, - CreditsPack, - PlanID, -} from "@app/components/shared/StripeCheckoutSaas"; - -const StripeCheckout = lazy( - () => import("@app/components/shared/StripeCheckoutSaas"), -); -import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection"; -import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection"; -import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection"; -import { useAuth } from "@app/auth/UseSession"; +/** + * SaaS "Plan" page — the single entry point for billing, plan state, and + * usage. Branches on the team's wallet state and the viewer's role: + * + * - free + leader → {@link PaygFreeLeader} (upgrade CTA + manual-tools + * framing) + * - free + member → {@link PaygFreeMember} (ask-the-owner note) + * - subscribed + leader → {@link PaygLeader} (full dashboard, editable cap) + * - subscribed + member → {@link PaygMember} (member dashboard) + * + *

The hook handles loading + error states locally so the four view + * components stay focused on rendering the data they own. {@code Plan} is + * intentionally tiny (under 60 lines) so future "Plan-level" affordances — + * a top-level error toast, a subscription confirmation card, etc — have + * obvious places to land. + */ +import React, { useCallback } from "react"; +import { Alert, Center, Loader } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useWallet } from "@app/hooks/useWallet"; +import { useRenderCount } from "@app/hooks/useRenderCount"; +import { + PaygLeader, + PaygMember, +} from "@app/components/shared/config/configSections/Payg"; +import { + PaygFreeLeader, + PaygFreeMember, +} from "@app/components/shared/config/configSections/PaygFree"; const Plan: React.FC = () => { - const [checkoutOpen, setCheckoutOpen] = useState(false); - const [selectedPlan, setSelectedPlan] = useState(null); - const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package - const [purchaseType, setPurchaseType] = - useState("subscription"); - const [selectedCreditsPack, setSelectedCreditsPack] = - useState(null); - const [currency, setCurrency] = useState("gbp"); - const { trialStatus } = useAuth(); - const { data, loading, error, updateCurrentPlan } = usePlans(currency); + useRenderCount("Plan"); + const { t } = useTranslation(); + const { wallet, loading, error, markSubscribed, updateCap, openPortal } = + useWallet(); - const currencyOptions = [ - { value: "cny", label: "Chinese yuan (CNY, ¥)" }, - { value: "usd", label: "US dollar (USD, $)" }, - { value: "inr", label: "Indian rupee (INR, ₹)" }, - { value: "brl", label: "Brazilian real (BRL, R$)" }, - { value: "eur", label: "Euro (EUR, €)" }, - { value: "idr", label: "Indonesian rupiah (IDR, Rp)" }, - { value: "gbp", label: "British pound (GBP, £)" }, - ]; - - const handleUpgradeClick = useCallback( - (plan: PlanTier) => { - if (!data) return; - - if (plan.isContactOnly) { - // Open contact form or redirect to contact page - window.open( - "mailto:contact@stirlingpdf.com?subject=Enterprise Plan Inquiry", - "_blank", - ); - return; - } - - if (plan.id !== data.currentPlan.id) { - setSelectedPlan(plan); - setPurchaseType("subscription"); - setSelectedCreditsPack(null); - setCheckoutOpen(true); - } + // Stable callback so PaygFreeLeader's React.memo doesn't see a new prop + // identity on every Plan render (e.g. loading flips false→true→false on + // a refetch). Closing over the stable markSubscribed from useWallet + // means we don't need to add wallet state to deps. + const onUpgraded = useCallback( + ({ capUsd }: { capUsd: number | null }) => { + // Bridges the modal's local success → backend mock → refetch loop. + // Real Stripe flow: the customer.subscription.created webhook is + // what flips status; we still call markSubscribed locally so the + // optimistic refetch hits immediately. + void markSubscribed(capUsd); }, - [data], + [markSubscribed], ); - const handleCreditPurchaseClick = useCallback( - (creditsPack: CreditsPack) => { - if (!data) return; - - setSelectedCreditsPack(creditsPack); - setPurchaseType("credits"); - setSelectedPlan(null); - setCheckoutOpen(true); - }, - [data], - ); - - const handlePaymentSuccess = useCallback( - (sessionId: string) => { - console.log("Payment successful, session:", sessionId); - - // Update local state immediately - no page reload needed - if (selectedPlan && purchaseType === "subscription") { - updateCurrentPlan(selectedPlan.id); - } - - // Close modal after brief delay to show success message - setTimeout(() => { - setCheckoutOpen(false); - setSelectedPlan(null); - setSelectedCreditsPack(null); - }, 2000); - }, - [selectedPlan, purchaseType, updateCurrentPlan], - ); - - const handlePaymentError = useCallback((error: string) => { - console.error("Payment error:", error); - // Error is already displayed in the StripeCheckout component - }, []); - - const handleCheckoutClose = useCallback(() => { - setCheckoutOpen(false); - setSelectedPlan(null); - setSelectedCreditsPack(null); - }, []); - - const handleAddPaymentClick = useCallback(() => { - if (!data) return; - - // Find Pro plan from available plans - const proPlan = Array.from(data.plans.values()).find( - (plan) => plan.id === "pro", - ); - - if (proPlan) { - setSelectedPlan(proPlan); - setPurchaseType("subscription"); - setSelectedCreditsPack(null); - setCheckoutOpen(true); - } - }, [data]); - - // Check URL parameters for action=add-payment - useEffect(() => { - if (!data) return; - - const params = new URLSearchParams(window.location.search); - if (params.get("action") === "add-payment") { - handleAddPaymentClick(); - // Clean up URL - params.delete("action"); - const newUrl = params.toString() - ? `${window.location.pathname}?${params.toString()}` - : window.location.pathname; - window.history.replaceState({}, "", newUrl); - } - }, [data, handleAddPaymentClick]); - - // Early returns after all hooks are called - if (loading) { + if (loading && !wallet) { return ( -

- -
+
+ +
); } - if (error) { + if (error || !wallet) { return ( - - {error} + + {error ?? + t( + "payg.error.body", + "We couldn't reach the billing service. Refresh the page to try again.", + )} ); } - if (!data) { - return ( - - Plans data is not available at the moment. - + if (wallet.status === "subscribed") { + return wallet.role === "leader" ? ( + + ) : ( + ); } - const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = - data; - const plansArray = Array.from(plans.values()); - - return ( -
- {/* Currency Selector */} -
- - - Currency - - onCustomInput(e.target.value)} + /> + + + + + {onSave && ( + + )} +
+ + {previewDocs != null && ( +
+ +
+
+ {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { + docs: previewDocs.toLocaleString(), + })} +
+
+ {t("payg.cap.docsRate", "at {{rate}} / PDF", { + rate: formatMinor(pricePerDocMinor ?? 0, currency), + })} +
+
+
+ )} + + {isNoCap && ( +
+ {t( + "payg.cap.noCapDesc", + "Usage is billed without an upper limit. You can re-enable a cap at any time.", + )} +
+ )} + + {note &&
{note}
} +
+ ); +}; + +export default SpendCapControl; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx new file mode 100644 index 000000000..4df290dde --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -0,0 +1,314 @@ +/** + * Stripe Embedded Checkout panel — lives in its own module so it can be + * imported lazily. {@code @stripe/stripe-js} pulls a fairly chunky third-party + * SDK; we don't want it in the main bundle for users who never open the + * UpgradeModal, let alone reach step 2. + * + *

Lazy-load pattern

+ * + *
+ * // In UpgradeModal.tsx — only when the user advances to step 2:
+ * const StripeCheckoutPanel = React.lazy(
+ *   () => import("./StripeCheckoutPanel"),
+ * );
+ * 
+ * + * The {@code loadStripe()} call inside this module deferred-imports the SDK + * itself, so the chunk graph is: + * + *
+ *   StripeCheckoutPanel.chunk.js
+ *     └─ @stripe/react-stripe-js  (pulled in by ESM static import here)
+ *     └─ @stripe/stripe-js        (pulled in by loadStripe inside fetchStripe)
+ * 
+ * + * Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the + * main bundle references either package. + * + *

Architecture

+ * + * Stripe-touching code lives in Supabase edge functions, not the Java + * backend. This panel invokes {@code create-checkout-session} + * directly via {@code supabase.functions.invoke()} — same pattern {@link + * usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is + * attached automatically by the Supabase client. + * + *

The edge function (SaaS PR #300) is the canonical place Stripe Checkout + * Sessions get created — it uses the Stripe Sync Engine tables, has dedicated + * unit tests, and shares Stripe SDK / secret-key plumbing with the metering + + * webhook edge functions. Routing through Java would have meant a useless + * proxy hop + a second Stripe SDK to maintain. + * + *

Behaviour

+ * + *
    + *
  1. On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id, + * currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is + * NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after + * the subscription lands. + *
  2. If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge + * function isn't deployed yet (errors out / returns a {@code cs_mock_} + * sentinel), render a clearly-labelled placeholder + "Continue with + * mock" button so the post-completion path stays testable. + *
  3. Otherwise render the real {@code } + + * {@code } iframe. + *
+ * + * The parent {@link UpgradeModal} passes {@code onComplete} which fires when + * either the real Stripe checkout emits its complete event OR the user + * presses "Continue with mock" in unconfigured environments. + */ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { supabase } from "@app/auth/supabase"; +import { useAuth } from "@app/auth/UseSession"; + +// Eager static imports here are OK because this whole module is itself lazy- +// imported by the modal. They land in the same lazy chunk. +import { + EmbeddedCheckout, + EmbeddedCheckoutProvider, +} from "@stripe/react-stripe-js"; +import type { Stripe } from "@stripe/stripe-js"; + +export interface StripeCheckoutPanelProps { + /** + * The caller's team_id — required by the Supabase edge function, which can't + * derive it from the JWT alone (the function runs outside our app's Spring + * Security context and has no access to the {@code team_memberships} table + * other than via this hint). + */ + teamId: number; + /** Currency lower-case 3-letter ISO (e.g. {@code "gbp"}). Selects the Stripe Price. */ + currency?: string; + /** Cap in USD; null means no cap. Tracked locally; set on the wallet via PATCH after subscription. */ + capUsd: number | null; + /** Called when Stripe (or the mock continue button) signals completion. */ + onComplete: () => void; + /** Called when the call to /api/v1/payg/checkout fails. */ + onError?: (message: string) => void; +} + +/** + * Response shape from the {@code create-checkout-session} Supabase edge + * function. Mirrors what the function returns. + */ +interface CheckoutResponse { + /** Stripe Checkout Session client_secret. */ + client_secret: string; + /** + * Optional sentinel: edge functions in non-prod environments may return a + * stubbed secret prefixed {@code cs_mock_} so the FE knows to render the + * placeholder rather than try to mount a real iframe with a bad secret. + */ + mock?: boolean; +} + +// Singleton Stripe promise — created on first use and reused for the lifetime +// of the tab. {@code loadStripe} is dynamically imported so the actual SDK +// chunk is only pulled when this code path runs. +let stripePromise: Promise | null = null; +function getStripe(publishableKey: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((mod) => + mod.loadStripe(publishableKey), + ); + } + return stripePromise; +} + +const StripeCheckoutPanel: React.FC = ({ + teamId, + currency = "gbp", + // capUsd is part of the props contract but intentionally unused here — the cap is set + // application-side via PATCH /payg/cap after the subscription lands, not during checkout. + onComplete, + onError, +}) => { + const { t } = useTranslation(); + const { user } = useAuth(); + const [clientSecret, setClientSecret] = useState(null); + const [isMock, setIsMock] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Billing email for the Stripe Checkout Session. Passing customer_email to + // Stripe prefills the email field AND locks it (Stripe's own behaviour for + // sessions created with customer_email or an attached customer) — the user + // can't bill a different address than the account they're signed in with. + const billingEmail = user?.email ?? null; + + // Stable ref for the error callback so we don't have to include it in the + // effect deps. + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + // Stash t() in a ref so the effect can read the current translator without + // forcing t into its deps. + const tRef = useRef(t); + tRef.current = t; + + const publishableKey = + import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; + + // Dev preview route has no backend — skip the API call and go straight + // to the mock placeholder so the design + completion path stay testable. + // Both checks required so a production tenant with a /dev/ URL prefix + // can't accidentally trigger the placeholder. + const devPreview = + import.meta.env.DEV && + typeof window !== "undefined" && + window.location.pathname.startsWith("/dev/"); + + useEffect(() => { + if (devPreview) { + setClientSecret("cs_mock_devpreview"); + setIsMock(true); + setLoading(false); + return; + } + + // React 18 strict-mode dev mounts effects twice. We use `cancelled` to + // discard the first mount's response (its setState calls become no-ops), + // and the second mount's response wins. We deliberately do NOT short- + // circuit the second mount with a ref — earlier versions did that and + // ran into the trap where mount 1 was cancelled but the live mount + // never re-fetched, leaving `loading` stuck at true. Two network calls + // in dev is an acceptable cost; prod has no strict mode → single fetch. + let cancelled = false; + async function createSession() { + try { + // Direct Supabase edge function invocation. We call + // {@code create-checkout-session} (not {@code create-payg-team-subscription} — + // that one creates a subscription directly without going through the + // hosted Stripe Embedded Checkout iframe and doesn't return a + // client_secret). team_id is required because the edge fn runs + // outside our Spring Security context and can't resolve it from the + // JWT alone. The cap is *not* set during checkout — it's an + // application-layer setting, applied via PATCH /payg/cap after the + // subscription lands. window.location.href is the success_url so the + // user comes back to the Plan tab after Stripe finishes the redirect. + const returnUrl = window.location.href; + const { data, error: invokeError } = + await supabase.functions.invoke( + "create-checkout-session", + { + body: { + team_id: teamId, + currency, + success_url: returnUrl, + cancel_url: returnUrl, + // Maps to Stripe's customer_email when the team has no Stripe + // customer yet — prefills + locks the email field in Checkout. + // Teams with an existing customer get the email locked from the + // customer record instead; this field is ignored for them. + ...(billingEmail ? { billing_owner_email: billingEmail } : {}), + }, + }, + ); + if (invokeError) { + throw invokeError; + } + if (!data?.client_secret) { + throw new Error("Edge function returned no client_secret"); + } + if (cancelled) return; + setClientSecret(data.client_secret); + setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_")); + } catch (e: unknown) { + if (cancelled) return; + const msg = + e instanceof Error + ? e.message + : tRef.current( + "payg.checkout.error.startFailed", + "Couldn't start checkout session", + ); + setError(msg); + onErrorRef.current?.(msg); + } finally { + if (!cancelled) setLoading(false); + } + } + void createSession(); + return () => { + cancelled = true; + }; + }, [teamId, currency, devPreview, billingEmail]); + + if (loading) { + return ( +
+
+ {t("payg.checkout.connecting", "Connecting to Stripe…")} +
+
+ ); + } + + if (error) { + return ( +
+
+ {t("payg.checkout.errorTitle", "Stripe error")} +
+
{error}
+
+ ); + } + + // Mock mode OR no publishable key configured → friendly placeholder. + const showMockPlaceholder = isMock || publishableKey.length === 0; + + if (showMockPlaceholder) { + return ( +
+
+ {t( + "payg.checkout.mock.title", + "Stripe Embedded Checkout (mock mode)", + )} +
+
+ {publishableKey.length === 0 + ? t( + "payg.checkout.mock.noKey", + "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured.", + ) + : t( + "payg.checkout.mock.backend", + "Backend is in mock mode — no real Stripe session was created.", + )} +
+
+ +
+
+ ); + } + + if (!clientSecret) return null; + + return ( +
+ + + +
+ ); +}; + +export default StripeCheckoutPanel; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css new file mode 100644 index 000000000..5840bd8df --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css @@ -0,0 +1,440 @@ +/* Upgrade-to-Processor modal: two steps inside one frame. + Step 1: pick a monthly cap. + Step 2: Stripe Embedded Checkout (or placeholder when no test key set). + The frame doesn't change between steps — only the panel slides — so the + user feels like they're filling out one form, not navigating pages. */ + +.upm { + --upm-accent: #0a8bff; + --upm-accent-2: #6c5ce7; + --upm-accent-soft: rgba(10, 139, 255, 0.08); + --upm-success: #10b981; + --upm-radius: 18px; + --upm-card-bg: var(--bg-surface); + --upm-border: var(--border-default); + --upm-divider: var(--border-subtle); + --upm-text: var(--text-primary); + --upm-muted: var(--text-muted); +} +[data-mantine-color-scheme="dark"] .upm { + --upm-card-bg: #313842; + --upm-border: #3d444e; + --upm-divider: #3d444e; + --upm-accent-soft: rgba(10, 139, 255, 0.14); +} + +/* Backdrop locks the page and centres the modal. z-index matches + Z_INDEX_OVER_SETTINGS_MODAL (styles/zIndex.ts) — must sit above the + AppConfigModal's 1300 (Z_INDEX_OVER_FULLSCREEN_SURFACE). The config modal + also hides itself while we're open (appConfig:overlay event), so this is + belt-and-braces for the brief overlap during open/close transitions. */ +.upm-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 1400; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + animation: upm-fade-in 180ms ease; +} + +@keyframes upm-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.upm-frame { + background: var(--upm-card-bg); + border: 1px solid var(--upm-border); + border-radius: var(--upm-radius); + width: 100%; + /* Stripe Embedded Checkout flips from its single-column layout to the two-column + "horizontal" one (order summary beside the payment form) once the iframe is + ~1000px wide — measured empirically against a live test session; Stripe doesn't + document the breakpoint. The live iframe gets frame_width − 44px (the 22px body + padding each side; the live mount's own padding is removed — see + .upm-stripe-mount[data-state="live"]). So 1100px → ~1056px iframe, comfortably + past the ~1000px threshold. Because the frame stays width:100% under this cap, + narrow windows shrink it and Stripe falls back to single column — horizontal + when it fits, vertical when it doesn't. Cap + confirm steps read fine at this + width (their internal text is already capped — see upm-confirm__body, + upm-section-help). */ + max-width: 1100px; + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.4); + animation: upm-pop-in 220ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes upm-pop-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ── Header: title + close + step dots ────────────────────────────────── */ +.upm-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 14px; + border-bottom: 1px solid var(--upm-divider); +} +.upm-header__title { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.005em; + color: var(--upm-text); + margin: 0; +} +.upm-header__close { + background: transparent; + border: none; + color: var(--upm-muted); + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, color 120ms ease; +} +.upm-header__close:hover { + background: var(--upm-divider); + color: var(--upm-text); +} + +/* Left cluster: optional back arrow + title. The back arrow only renders on the + checkout step (cap/confirm have no parent step to return to) — it replaces the + old footer "← Back" button. */ +.upm-header__left { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.upm-header__back { + background: transparent; + border: none; + color: var(--upm-muted); + cursor: pointer; + width: 32px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: -6px; + transition: background 120ms ease, color 120ms ease; +} +.upm-header__back:hover { + background: var(--upm-divider); + color: var(--upm-text); +} + +.upm-steps { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 22px 14px; + background: var(--upm-accent-soft); + border-bottom: 1px solid var(--upm-divider); +} +.upm-step { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + font-size: 0.78rem; + font-weight: 600; + color: var(--upm-muted); + transition: color 200ms ease; +} +.upm-step__dot { + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--upm-card-bg); + border: 1.5px solid var(--upm-divider); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; + transition: background 200ms ease, border-color 200ms ease, color 200ms ease; +} +.upm-step[data-state="active"] { + color: var(--upm-text); +} +.upm-step[data-state="active"] .upm-step__dot { + background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2)); + border-color: transparent; + color: white; +} +.upm-step[data-state="done"] { + color: var(--upm-success); +} +.upm-step[data-state="done"] .upm-step__dot { + background: var(--upm-success); + border-color: transparent; + color: white; +} +.upm-step__connector { + flex: 1 1 auto; + height: 1.5px; + background: var(--upm-divider); + margin: 0 4px; +} + +/* ── Body: slides between steps without remounting ───────────────────── */ +.upm-body { + padding: 22px; + overflow-y: auto; + flex: 1 1 auto; +} + +/* Hero promise reinforcement — only shown on step 1 */ +.upm-promise { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + background: var(--upm-accent-soft); + border-radius: 12px; + margin-bottom: 18px; + font-size: 0.85rem; + line-height: 1.45; + color: var(--upm-text); +} +.upm-promise__icon { + flex-shrink: 0; + color: var(--upm-accent); + margin-top: 1px; +} +.upm-promise__highlight { + font-weight: 700; +} + +.upm-section-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--upm-text); + margin: 0 0 4px; + letter-spacing: -0.005em; +} +.upm-section-help { + font-size: 0.825rem; + color: var(--upm-muted); + margin: 0 0 14px; + line-height: 1.45; +} + +/* Cap selection (presets + custom + no-cap + estimate) now lives in the shared + SpendCapControl (SpendCapControl.css). The old upm-cap-* / upm-no-cap-toggle + rules were removed with it. */ + +/* What counts as "processed" — quiet helper text */ +.upm-help-card { + background: var(--upm-divider); + border-radius: 10px; + padding: 12px 14px; + margin-top: 14px; + font-size: 0.78rem; + line-height: 1.5; + color: var(--upm-muted); +} +.upm-help-card__title { + display: block; + font-weight: 700; + color: var(--upm-text); + margin-bottom: 4px; + font-size: 0.8rem; +} + +/* On the checkout step, step 1's "Set monthly ceiling" label is replaced (inside + the step bar) by the chosen ceiling + an inline Edit affordance — see + UpgradeModal.tsx. Kept here next to the step-bar markup it styles. */ +.upm-step__chosen { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--upm-text); + font-weight: 700; +} +.upm-step__edit { + background: transparent; + border: none; + color: var(--upm-accent); + font-weight: 600; + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 6px; +} +.upm-step__edit:hover { + background: rgba(10, 139, 255, 0.12); +} + +/* Placeholder card for the loading / error / mock states — a dashed, centered + box with helper text. The live Stripe iframe is NOT meant to wear this chrome; + it gets a full-width reset below (data-state="live"). */ +.upm-stripe-mount { + min-height: 220px; + border: 1.5px dashed var(--upm-divider); + border-radius: 12px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 8px; + color: var(--upm-muted); + font-size: 0.875rem; +} + +/* Live Stripe Embedded Checkout — strip the placeholder card chrome so the + iframe spans the full body width and Stripe renders its two-column + "horizontal" layout. Without this the dashed border + 24px padding + centering + above box the iframe ~48px narrower, dropping it back to single column. */ +.upm-stripe-mount[data-state="live"] { + display: block; + border: none; + padding: 0; + min-height: 0; + text-align: left; +} +.upm-stripe-mount__title { + font-weight: 700; + color: var(--upm-text); + font-size: 0.95rem; +} +.upm-stripe-mount__code { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.75rem; + padding: 2px 6px; + background: var(--upm-card-bg); + border-radius: 4px; + border: 1px solid var(--upm-divider); + color: var(--upm-text); +} + +/* ── Footer / nav actions ─────────────────────────────────────────────── */ +.upm-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + padding: 16px 22px; + border-top: 1px solid var(--upm-divider); + background: var(--upm-card-bg); +} +.upm-footer__actions { + display: flex; + gap: 8px; +} +.upm-btn { + padding: 10px 18px; + border-radius: 10px; + border: 1.5px solid transparent; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 120ms ease; + font-family: inherit; + white-space: nowrap; +} +.upm-btn[data-variant="ghost"] { + background: transparent; + border-color: var(--upm-divider); + color: var(--upm-text); +} +.upm-btn[data-variant="ghost"]:hover { + border-color: var(--upm-accent); + color: var(--upm-accent); +} +.upm-btn[data-variant="primary"] { + background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2)); + color: white; +} +.upm-btn[data-variant="primary"]:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5); +} +.upm-btn[disabled] { + opacity: 0.5; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +/* ── Step 3: confirmation ─────────────────────────────────────────────── */ + +.upm-confirm { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 12px 8px 4px; + gap: 12px; +} +.upm-confirm__icon { + font-size: 64px !important; + color: #10b981; + filter: drop-shadow(0 6px 18px rgba(16, 185, 129, 0.35)); +} +.upm-confirm__title { + margin: 6px 0 0; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.015em; + color: var(--upm-text); +} +.upm-confirm__body { + margin: 0; + font-size: 0.95rem; + color: var(--upm-text-muted); + max-width: 380px; + line-height: 1.55; +} +.upm-confirm__summary { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + max-width: 360px; + padding: 14px 18px; + background: var(--upm-inset-bg, rgba(0, 0, 0, 0.03)); + border: 1px solid var(--upm-divider); + border-radius: 12px; + margin-top: 6px; + font-size: 0.92rem; +} +.upm-confirm__summary > strong { + color: var(--upm-text); + font-size: 1.05rem; +} +.upm-confirm__note { + margin: 4px 0 0; + font-size: 0.78rem; + color: var(--upm-text-muted); + max-width: 380px; + line-height: 1.5; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx new file mode 100644 index 000000000..ec9f58723 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx @@ -0,0 +1,532 @@ +/** + * Upgrade-to-Processor modal. Three sequential panels inside one frame: + * + * Step 1: Cap selection — local state only, no side effects + * Step 2: Stripe Checkout — POSTs to /api/v1/payg/checkout, mounts the + * Stripe Embedded Checkout iframe (lazy-loaded) + * Step 3: Confirmation — brief "Welcome to Processor" beat before + * the modal closes and the parent's + * {@code onComplete} triggers a wallet refetch + * + *

The Stripe SDK ({@code @stripe/stripe-js} + {@code @stripe/react-stripe-js}) + * is loaded via {@code React.lazy} on a dedicated module so the chunk only + * downloads when the user actually advances to step 2. The main bundle pays + * nothing for users who never open the modal. + * + *

Cap state is held locally — nothing reaches the backend until the user + * commits in step 2. A user who cancels mid-modal leaves no side effects. + */ +import React, { Suspense, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import CloseIcon from "@mui/icons-material/CloseRounded"; +import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded"; +import ShieldIcon from "@mui/icons-material/ShieldOutlined"; +import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded"; +import { useTranslation } from "react-i18next"; +// eslint-disable-next-line no-restricted-imports +import "./UpgradeModal.css"; +// eslint-disable-next-line no-restricted-imports +import SpendCapControl from "./SpendCapControl"; + +/** + * Tell the AppConfigModal (or any other full-screen surface listening) that an + * upgrade overlay is opening/closing so it can hide itself rather than stack + * under us. Same window-event pattern the config modal already uses for + * appConfig:navigate / appConfig:notice. + */ +function dispatchOverlay(open: boolean) { + window.dispatchEvent( + new CustomEvent("appConfig:overlay", { detail: { open } }), + ); +} + +// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user +// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the +// chunk-graph reasoning. +const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel")); + +interface UpgradeModalProps { + open: boolean; + /** + * The caller's team_id. Threaded through to {@link StripeCheckoutPanel} so the + * {@code create-checkout-session} edge function can scope the subscription to + * the right team. + */ + teamId: number; + /** Called when the user closes the modal without completing checkout. */ + onClose: () => void; + /** + * Called after the user confirms cap + completes Stripe checkout. The parent + * is expected to refresh the wallet snapshot which will then show the + * subscribed state. + */ + onComplete: (result: { capUsd: number | null }) => void; + /** ISO 4217 currency code for the cap input. Default USD. */ + currency?: "USD" | "EUR" | "GBP"; + /** + * The team's one-time free grant in documents — the real {@code + * wallet.freeAllowance}, threaded from the free-leader view so the step copy + * quotes the backend's number instead of a hardcoded one. A lifetime grant, + * not a monthly one. + */ + freeLimit: number; + /** + * Per-document rate in minor units for the live "≈ N paid PDFs/month" + * estimate, threaded from {@code wallet.pricePerDocMinor}. For unsubscribed + * teams the backend resolves this from the default pricing policy's USD + * Price (Stripe hasn't assigned the team a currency yet). Null hides the + * estimate. + */ + pricePerDocMinor?: number | null; + /** Lower-case ISO currency of {@link #pricePerDocMinor} (e.g. {@code "usd"}). */ + rateCurrency?: string | null; +} + +type Step = "cap" | "checkout" | "confirm"; + +function currencySymbol(c: UpgradeModalProps["currency"]): string { + switch (c) { + case "EUR": + return "€"; + case "GBP": + return "£"; + default: + return "$"; + } +} + +export default function UpgradeModal({ + open, + teamId, + onClose, + onComplete, + currency = "USD", + freeLimit, + pricePerDocMinor, + rateCurrency, +}: UpgradeModalProps) { + const { t } = useTranslation(); + const [step, setStep] = useState("cap"); + const [capUsd, setCapUsd] = useState(500); + const [noCap, setNoCap] = useState(false); + + // The config modal hides itself while we're open (it listens for this event) + // so the upgrade flow visually REPLACES it instead of stacking inside it. + // Cleanup fires open=false on unmount too, so the config modal can't get + // stuck hidden if we unmount without a clean close. + useEffect(() => { + dispatchOverlay(open); + return () => dispatchOverlay(false); + }, [open]); + + if (!open) { + return null; + } + + const effectiveCap = noCap ? null : capUsd; + const sym = currencySymbol(currency); + + const goToCheckout = () => setStep("checkout"); + const goBackToCap = () => setStep("cap"); + const goToConfirm = () => setStep("confirm"); + + // Modal close → reset internal step so reopening starts at step 1. + const closeAndReset = () => { + setStep("cap"); + onClose(); + }; + + // Portal to document.body so the overlay escapes the config modal's portal / + // stacking context. Without this the fixed-position backdrop layers inside + // the Mantine modal (z-index 1300) instead of over the whole page, producing + // the modal-in-modal look. + return createPortal( +

+
+
e.stopPropagation()}> + {/* Header — title + close. Title stays constant; the step indicator + below tells the user where they are. */} +
+
+ {step === "checkout" && ( + + )} +

+ {step === "confirm" + ? t("payg.upgrade.title.confirm", "You're subscribed") + : t("payg.upgrade.title.default", "Upgrade to Processor plan")} +

+
+ +
+ + {/* Step indicator. Hidden on the confirmation panel since the + modal is winding down at that point. */} + {step !== "confirm" && ( +
+
+ 1 + {step === "checkout" ? ( + + {effectiveCap === null + ? t("payg.upgrade.checkout.noCap", "No cap") + : t( + "payg.upgrade.checkout.capValue", + "{{symbol}}{{amount}} / month", + { symbol: sym, amount: effectiveCap }, + )} + + + ) : ( + + {t("payg.upgrade.steps.cap", "Set monthly ceiling")} + + )} +
+
+
+ 2 + + {t( + "payg.upgrade.steps.payment", + "Add payment method", + )} + +
+
+ )} + +
+ {step === "cap" && ( + + )} + {step === "checkout" && ( + // Keyed on the cap value so editing the cap → returning to + // step 2 unmounts + remounts the panel, triggering a fresh + // POST /checkout for the new cap. Without the key, the + // StripeCheckoutPanel's fetchedRef short-circuits and the + // session keeps the stale cap. + + )} + {step === "confirm" && ( + + )} +
+ + {step !== "checkout" && ( +
+
+ {step === "cap" && ( + <> + + + + )} + {step === "confirm" && ( + + )} +
+
+ )} +
+
+
, + document.body, + ); +} + +// ─── Step 1: cap selection ────────────────────────────────────────────── + +interface CapStepProps { + capUsd: number; + setCapUsd: (v: number) => void; + noCap: boolean; + setNoCap: (v: boolean) => void; + pricePerDocMinor?: number | null; + rateCurrency?: string | null; +} + +function CapStep({ + capUsd, + setCapUsd, + noCap, + setNoCap, + pricePerDocMinor, + rateCurrency, +}: CapStepProps) { + const { t } = useTranslation(); + + return ( + <> +
+ +
+ + {t( + "payg.upgrade.promise.highlight", + "Manual tools stay free, always.", + )} + {" "} + {t( + "payg.upgrade.promise.body", + "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge.", + )} +
+
+ +

+ {t( + "payg.upgrade.cap.title", + "Set your monthly spend ceiling", + )} +

+

+ {t( + "payg.upgrade.cap.help", + "We'll never charge above this. Set $0 if you want to keep everything free while testing.", + )} +

+ + {/* Same control the subscribed plan page renders. null = no cap; the + shared control owns the presets, the inline custom-entry pill, the + no-cap chip, and the live processed-PDF estimate. */} + { + if (v === null) { + setNoCap(true); + } else { + setNoCap(false); + setCapUsd(v); + } + }} + pricePerDocMinor={pricePerDocMinor} + currency={rateCurrency} + note={t( + "payg.upgrade.cap.usdNote", + "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency.", + )} + /> + +
+ + {t( + "payg.upgrade.help.title", + "What we count toward billing", + )} + +
    +
  • + + {t( + "payg.upgrade.help.automationTitle", + "Automation pipelines", + )} + + {" — "} + {t( + "payg.upgrade.help.automationBody", + "chained tools or scheduled runs that don't need clicks", + )} +
  • +
  • + + {t("payg.upgrade.help.aiTitle", "AI tools")} + + {" — "} + {t( + "payg.upgrade.help.aiBody", + "summarise, classify, redact, AI-OCR", + )} +
  • +
  • + + {t("payg.upgrade.help.apiTitle", "API calls")} + + {" — "} + {t( + "payg.upgrade.help.apiBody", + "programmatic access to any Stirling endpoint", + )} +
  • +
+
+ {t( + "payg.upgrade.help.footnote", + "Manual tools — viewing, editing, merging, splitting, signing, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click.", + )} +
+
+ + ); +} + +// ─── Step 2: Stripe Embedded Checkout (lazy-loaded) ──────────────────── + +interface CheckoutStepProps { + teamId: number; + effectiveCap: number | null; + currency: UpgradeModalProps["currency"]; + onComplete: () => void; +} + +function CheckoutStep({ + teamId, + effectiveCap, + currency, + onComplete, +}: CheckoutStepProps) { + const { t } = useTranslation(); + return ( + <> +

+ {t( + "payg.upgrade.checkout.title", + "Add your payment method", + )} +

+

+ {t( + "payg.upgrade.checkout.help", + "Stripe handles your card details. Stirling never sees them.", + )} +

+ + +
+ {t("payg.upgrade.checkout.loading", "Loading checkout…")} +
+
+ } + > + + + + ); +} + +// ─── Step 3: confirmation ────────────────────────────────────────────── + +interface ConfirmationStepProps { + effectiveCap: number | null; + currency: UpgradeModalProps["currency"]; + freeLimit: number; +} + +function ConfirmationStep({ + effectiveCap, + currency, + freeLimit, +}: ConfirmationStepProps) { + const { t } = useTranslation(); + const sym = currencySymbol(currency); + return ( +
+ +

+ {t( + "payg.confirm.title", + "Welcome to the Processor plan", + )} +

+

+ {t( + "payg.confirm.body", + "Your team can now process documents with automation, AI, and the API beyond your {{limit}} free PDFs.", + { limit: freeLimit.toLocaleString() }, + )} +

+
+ {t("payg.confirm.summaryLabel", "Monthly ceiling")} + + {effectiveCap === null + ? t("payg.confirm.noCap", "No cap") + : t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", { + symbol: sym, + amount: effectiveCap, + })} + +
+

+ {t( + "payg.confirm.note", + "You can change your cap, cancel, or open the Stripe customer portal any time from this page.", + )} +

+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts index 5de25063f..5f0f0a321 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts +++ b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts @@ -1,79 +1,32 @@ import { useCallback, useEffect, useState } from "react"; -import apiClient from "@app/services/apiClient"; import { useAuth } from "@app/auth/UseSession"; import { ApiCredits } from "@app/types/credits"; import { isUserAnonymous } from "@app/auth/supabase"; -function coerceNumber(value: unknown, fallback = 0): number { - const n = typeof value === "string" ? Number(value) : (value as number); - return Number.isFinite(n) ? n : fallback; -} - -function normalizeCredits(raw: Record): ApiCredits { - // Accept a variety of possible backend keys to be resilient - return { - weeklyCreditsRemaining: coerceNumber( - raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left, - ), - weeklyCreditsAllocated: coerceNumber( - raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total, - ), - boughtCreditsRemaining: coerceNumber( - raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left, - ), - totalBoughtCredits: coerceNumber( - raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total, - ), - totalAvailableCredits: coerceNumber( - raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total, - ), - weeklyResetDate: String( - raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "", - ), - lastApiUsage: String( - raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "", - ), - }; -} - export function useCredits() { const { session, loading, user } = useAuth(); const isAnonymous = Boolean(user && isUserAnonymous(user)); - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + // Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only + // hasAttempted toggles, so the rest have no setters. + const [data] = useState(null); + const [isLoading] = useState(false); + const [error] = useState(null); const [hasAttempted, setHasAttempted] = useState(false); + // Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the + // wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The + // hook surface is preserved for the ApiKeys page consumer (it destructures `data`, + // `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null — + // the consumer's usage widget will render its "no data" state. const fetchCredits = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const res = - await apiClient.get>("/api/v1/credits"); - const normalized = normalizeCredits(res.data); - // If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton - const isEmpty = - !normalized.weeklyCreditsAllocated && - !normalized.weeklyCreditsRemaining && - !normalized.totalBoughtCredits && - !normalized.boughtCreditsRemaining && - !normalized.totalAvailableCredits && - !normalized.weeklyResetDate && - !normalized.lastApiUsage; - setData(isEmpty ? null : normalized); - } catch (e: unknown) { - setError(e instanceof Error ? e : new Error(String(e))); - } finally { - setIsLoading(false); - setHasAttempted(true); - } + setHasAttempted(true); }, []); useEffect(() => { if (!loading && session && !hasAttempted && !isAnonymous) { - fetchCredits(); + setHasAttempted(true); } - }, [loading, session, hasAttempted, isAnonymous, fetchCredits]); + }, [loading, session, hasAttempted, isAnonymous]); return { data, diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx deleted file mode 100644 index c3c18ecaa..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from "react"; -import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core"; -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import CreditCardIcon from "@mui/icons-material/CreditCard"; -import { useTranslation } from "react-i18next"; -import { PlanTier } from "@app/hooks/usePlans"; -import { ManageBillingButton } from "@app/components/shared/ManageBillingButton"; - -interface TrialStatus { - isTrialing: boolean; - trialEnd: string; - daysRemaining: number; - hasPaymentMethod: boolean; - hasScheduledSub: boolean; -} - -interface ActivePlanSectionProps { - currentPlan: PlanTier; - _activeSince?: string; - _nextBillingDate?: string; - trialStatus?: TrialStatus; - onAddPaymentClick?: () => void; -} - -const ActivePlanSection: React.FC = ({ - currentPlan, - _activeSince, - _nextBillingDate, - trialStatus, - onAddPaymentClick, -}) => { - const { t } = useTranslation(); - - return ( -
- -

- {t("plan.activePlan.title", "Active Plan")} -

- -
-

- {t("plan.activePlan.subtitle", "Your current subscription details")} -

- - {/* Trial Status Alert */} - {trialStatus?.isTrialing && ( - } - mt="md" - mb="md" - title={t("plan.trial.title", "Free Trial Active")} - > - - {t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", { - days: trialStatus.daysRemaining, - })} - - - {t("plan.trial.endDate", "Expires: {{date}}", { - date: new Date(trialStatus.trialEnd).toLocaleDateString(), - })} - - {trialStatus.hasScheduledSub ? ( - - ✓{" "} - {t( - "plan.trial.subscriptionScheduled", - "Subscription scheduled - starts {{date}}", - { - date: new Date(trialStatus.trialEnd).toLocaleDateString(), - }, - )} - - ) : ( - onAddPaymentClick && ( - - ) - )} - - )} - - - -
- - - {currentPlan.name} - - {trialStatus?.isTrialing && ( - - {t("plan.trial.badge", "Trial")} - - )} - - {/* {activeSince && ( - - {t('plan.activeSince', 'Active since {{date}}', { date: activeSince })} - - )} */} -
-
- - {currentPlan.currency} - {currentPlan.price}/month - - {/* {nextBillingDate && ( - - {t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })} - - )} */} -
-
-
-
- ); -}; - -export default ActivePlanSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx deleted file mode 100644 index f707d5d9a..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from "react"; -import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas"; - -interface ApiPackage { - id: string; - name: string; - credits: number; - price: number; - currency: string; - description: string; -} - -interface ApiPackagesSectionProps { - apiPackages: ApiPackage[]; - selectedCredits: number; - onSelectedCreditsChange: (value: number) => void; - onCreditPurchaseClick: (creditsPack: CreditsPack) => void; -} - -const ApiPackagesSection: React.FC = ({ - apiPackages, - selectedCredits, - onSelectedCreditsChange, - onCreditPurchaseClick, -}) => { - const { t } = useTranslation(); - - return ( -
-

- {t("plan.apiPackages.title", "API Credit Packages")} -

-

- {t( - "plan.apiPackages.subtitle", - "Purchase API credits for your applications", - )} -

- - - - {/* Credits Selection */} -
- - {t("plan.selectCredits", "Select Credit Amount")} - - -
- - onSelectedCreditsChange(Math.round(value)) - } - min={0} - max={3} - step={0.01} - marks={[ - { value: 0, label: "100" }, - { value: 1, label: "500" }, - { value: 2, label: "1K" }, - { value: 3, label: "5K" }, - ]} - size="lg" - className="mb-6" - label={null} - /> -
-
- - {/* Selected Package Display */} - -
- - {apiPackages[ - Math.round(selectedCredits) - ].credits.toLocaleString()}{" "} - Credits - - - {apiPackages[Math.round(selectedCredits)].description} - -
- -
- - {apiPackages[Math.round(selectedCredits)].currency} - {apiPackages[Math.round(selectedCredits)].price} - - - {t("plan.totalCost", "Total Cost")} - -
- - -
-
-
-
- ); -}; - -export default ApiPackagesSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx deleted file mode 100644 index 3942636bd..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { useState } from "react"; -import { Button, Card, Badge, Text, Collapse } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { PlanTier } from "@app/hooks/usePlans"; -import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard"; - -interface AvailablePlansSectionProps { - plans: PlanTier[]; - currentPlan?: PlanTier; - currentLicenseInfo?: unknown; - onUpgradeClick: (plan: PlanTier) => void; - onManageClick?: (plan: PlanTier) => void; - currency?: string; - onCurrencyChange?: (currency: string) => void; - currencyOptions?: Array<{ value: string; label: string }>; - loginEnabled?: boolean; -} - -const AvailablePlansSection: React.FC = ({ - plans, - currentPlan, - onUpgradeClick, -}) => { - const { t } = useTranslation(); - const [showComparison, setShowComparison] = useState(false); - - const isUserProOrAbove = - currentPlan?.id === "pro" || currentPlan?.id === "enterprise"; - - return ( -
-

- {t("plan.availablePlans.title", "Available Plans")} -

-

- {t( - "plan.availablePlans.subtitle", - "Choose the plan that fits your needs", - )} -

- -
- {plans.map((plan) => ( - - ))} -
- -
- -
- - - - - {t("plan.featureComparison", "Feature Comparison")} - - -
- - - - - {plans.map((plan) => ( - - ))} - - - - {plans[0].features.map((_, featureIndex) => ( - - - {plans.map((plan) => ( - - ))} - - ))} - -
- {t("plan.feature.title", "Feature")} - - {plan.name} - {plan.popular && ( - - {t("plan.popular", "Popular")} - - )} -
- {plans[0].features[featureIndex].name} - - {plan.features[featureIndex].included ? ( - - ✓ - - ) : ( - - - )} -
-
-
-
-
- ); -}; - -export default AvailablePlansSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx deleted file mode 100644 index 6fde6ee49..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from "react"; -import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { PlanTier } from "@app/hooks/usePlans"; - -interface PlanCardProps { - plan?: PlanTier; - planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility - isCurrentPlan?: boolean; - isCurrentTier?: boolean; - isDowngrade?: boolean; - isUserProOrAbove?: boolean; - currentLicenseInfo?: unknown; - currentTier?: string | null; // Accept null for proprietary compatibility - onUpgradeClick?: (plan: PlanTier) => void; - onManageClick?: (plan: PlanTier) => void; - loginEnabled?: boolean; -} - -const PlanCard: React.FC = ({ - plan: propPlan, - planGroup, - isCurrentPlan, - isCurrentTier: _isCurrentTier, - isDowngrade: _isDowngrade, - isUserProOrAbove, - currentLicenseInfo: _currentLicenseInfo, - currentTier: _currentTier, - onUpgradeClick, - onManageClick: _onManageClick, - loginEnabled: _loginEnabled, -}) => { - // Use plan from props, or extract from planGroup if proprietary is using it - const plan = propPlan || planGroup?.monthly || planGroup?.yearly; - const { t } = useTranslation(); - - if (!plan) return null; // Safety check - - const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove; - - return ( - - {plan.popular && ( - - {t("plan.popular", "Popular")} - - )} - - -
- - {plan.name} - - - - {plan.isContactOnly - ? t("plan.customPricing", "Custom") - : `${plan.currency}${plan.price}`} - - {!plan.isContactOnly && ( - - {plan.period} - - )} - -
- - - {plan.highlights.map((highlight: string, index: number) => ( - - • {highlight} - - ))} - - -
- - {!shouldHideUpgrade && ( - - )} - - - ); -}; - -export default PlanCard; diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 9ec28f42e..562c010ad 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -151,7 +151,11 @@ function appendMcpSection( export function createSaasConfigNavSections( Overview: OverviewComponent, onLogoutClick: () => void, - { isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions, + { + isDev = false, + isAnonymous = false, + t, + }: CreateSaasConfigNavSectionsOptions, ): ConfigNavSection[] { const baseSections = createCoreConfigNavSections(false, false, false); @@ -203,6 +207,9 @@ export function createSaasConfigNavSections( sections = appendMcpSection(sections, t); if (!isAnonymous) { + // The Plan tab is now the single billing surface — it internally branches + // free vs subscribed × leader vs member via useWallet(). The old separate + // "Pay-as-you-go" tab and paygEnabled / isLeader options were removed. sections = appendBillingSection(sections, t); } diff --git a/frontend/editor/src/saas/hooks/useCreditCheck.ts b/frontend/editor/src/saas/hooks/useCreditCheck.ts index 54ad36d7c..6a9863ad9 100644 --- a/frontend/editor/src/saas/hooks/useCreditCheck.ts +++ b/frontend/editor/src/saas/hooks/useCreditCheck.ts @@ -1,63 +1,26 @@ import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { useCredits } from "@app/hooks/useCredits"; -import { getToolCreditCost } from "@app/utils/creditCosts"; -import { openPlanSettings } from "@app/utils/appSettings"; -import type { ToolId } from "@app/types/toolId"; - -export function useCreditCheck(operationType?: string, _endpoint?: string) { - const { hasSufficientCredits, isPro, creditBalance, refreshCredits } = - useCredits(); - const { t } = useTranslation(); +/** + * Pre-flight credit-balance check, formerly run before every billable tool call. + * + * Replaced by PAYG's reactive 402 FEATURE_DEGRADED handler (see paygErrorInterceptor.ts): + * we no longer try to predict whether the user has "enough credits" before the request — + * we make the request, and if the wallet hits the free-tier ceiling the BE returns 402 + * with a discriminating code that the global axios interceptor turns into a toast + + * prompt-to-add-card. This is more accurate (no race between FE balance cache and the + * BE's atomic debit) and avoids the round-trip latency of a pre-flight call. + * + * The hook signature is preserved as a no-op so {@code useToolOperation} (the sole + * caller) compiles without modification. {@code checkCredits} always resolves to null + * — the BE's 402 handler is now the only gate. + */ +export function useCreditCheck( + _operationType?: string, + _endpoint?: string, +): { checkCredits: (_runtimeEndpoint?: string) => Promise } { const checkCredits = useCallback( - async (_runtimeEndpoint?: string): Promise => { - const requiredCredits = getToolCreditCost(operationType as ToolId); - const creditCheck = hasSufficientCredits(requiredCredits); - - if (creditBalance === null) { - try { - await refreshCredits(); - } catch (_e) { - void _e; - } - return t("loadingCredits", "Checking credits..."); - } - - if (isPro === null) { - return t("loadingProStatus", "Checking subscription status..."); - } - - if (!isPro && !creditCheck.hasSufficientCredits) { - const shortfall = creditCheck.shortfall || 0; - const error = t( - "insufficientCredits", - "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}", - { - requiredCredits, - currentBalance: creditCheck.currentBalance, - shortfall, - }, - ); - const notice = t( - "noticeTopUpOrPlan", - "Not enough credits, please top up or upgrade to a plan", - ); - openPlanSettings(notice); - return error; - } - - return null; - }, - [ - hasSufficientCredits, - isPro, - creditBalance, - refreshCredits, - operationType, - t, - ], + async (_runtimeEndpoint?: string): Promise => null, + [], ); - return { checkCredits }; } diff --git a/frontend/editor/src/saas/hooks/useRenderCount.ts b/frontend/editor/src/saas/hooks/useRenderCount.ts new file mode 100644 index 000000000..c3d3a699f --- /dev/null +++ b/frontend/editor/src/saas/hooks/useRenderCount.ts @@ -0,0 +1,34 @@ +/** + * Dev-only render counter. Logs every render with a stable label so we can + * eyeball excessive re-renders during PAYG Plan-page work. Counts to + * {@code window.__renderCounts[label]} so a Playwright eval can assert + * "Plan rendered ≤ 3 times after a wallet refetch." + * + *

In production builds {@code import.meta.env.DEV} is constant-folded to + * {@code false}, so the module-level constant {@code IS_DEV} below allows + * Vite's dead-code-elimination to drop the whole hook body — no extra refs, + * no extra renders, no window pollution. + */ +import { useRef } from "react"; + +declare global { + interface Window { + __renderCounts?: Record; + } +} + +const IS_DEV = import.meta.env.DEV; + +export function useRenderCount(label: string): number { + // useRef must run unconditionally to satisfy rules-of-hooks. In production + // the rest of the body is dead-code-eliminated, leaving just this single + // ref allocation. + const count = useRef(0); + if (!IS_DEV) return 0; + count.current += 1; + if (typeof window !== "undefined") { + if (!window.__renderCounts) window.__renderCounts = {}; + window.__renderCounts[label] = count.current; + } + return count.current; +} diff --git a/frontend/editor/src/saas/hooks/useWallet.ts b/frontend/editor/src/saas/hooks/useWallet.ts new file mode 100644 index 000000000..5daf04242 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.ts @@ -0,0 +1,526 @@ +/** + * Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet} + * (served by {@code PaygWalletController} once Wave 1 BE lands; until then + * the dev preview route synthesises a wallet from localStorage) and exposes + * mutations for marking-subscribed and updating-the-cap. + * + *

Render efficiency

+ * + * The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and + * {@code PaygLeader/Member} re-render only on actual data change: + * + *
    + *
  • {@link Wallet} snapshot is stored as a plain object — but every + * successful fetch deep-compares with the previous snapshot and reuses + * the prior reference if the payload is unchanged. Consumers that hold + * a stable {@code wallet} reference get stable child memoisation. + *
  • The returned {@link UseWalletResult} keeps stable callback identities + * via {@code useCallback}. {@code Plan} can pass {@code markSubscribed} + * to {@code UpgradeModal} without forcing a remount. + *
  • {@code refetch / markSubscribed / updateCap} bump an internal counter + * that the {@code useEffect} watches — no global state plumbing. + *
  • A monotonic {@code requestId} ref drops stale responses so a slow + * refetch from tick=N can't overwrite a faster one from tick=N+1 + * (out-of-order resolution would otherwise show old data). + *
+ * + *

Mutation semantics

+ * + * Both {@code markSubscribed} and {@code updateCap} resolve only after the + * post-mutation wallet refetch completes. So callers like the cap-editor + * "Update cap" button that gate a {@code loading} state on the returned + * promise see the UI flip exactly once the new state is visible — no + * intermediate flash of the old value. + * + *

Dev preview fallback

+ * + * When the hook is rendered outside the saas app (e.g. on {@code + * /dev/payg-preview} during local design work) the {@code AppConfigContext} + * provider is not mounted and no backend is available. The hook detects that + * via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to + * a synthesised snapshot whose subscription state is read from + * {@code localStorage} (key {@code stirling.payg.devSubscription}). Both + * conditions are required so a production tenant whose URL happens to start + * with {@code /dev/} can't trigger the fallback. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import apiClient from "@app/services/apiClient"; +import { supabase } from "@app/auth/supabase"; + +// ─── Public types ─────────────────────────────────────────────────────── + +export type WalletStatus = "free" | "subscribed"; +export type WalletRole = "leader" | "member"; + +/** + * A single team member's billing-relevant info — name + email for the avatar + * row, {@code spendUnits} for their per-member usage display. Mirrors a row of + * the backend's {@code members} array on {@code WalletSnapshot} (joined with + * {@code team_memberships}). + */ +export interface WalletMember { + /** Supabase user id of the member. */ + userId: string; + name: string; + email: string; + /** Member's current-period billable spend. */ + spendUnits: number; +} + +/** + * Per-category breakdown of current-period spend in billable units. The + * categories mirror the {@code FeatureGate} buckets the backend tracks: + * server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and + * pipeline / automation runs ({@code automation}). Numbers sum to {@code + * billableUsed} (modulo rounding in mock data). + */ +export interface WalletCategoryBreakdown { + api: number; + ai: number; + automation: number; +} + +/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */ +export interface Wallet { + /** + * The caller's primary team_id. Needed when invoking Supabase edge functions + * (create-checkout-session, etc.) that run outside Spring Security and have + * no other way to resolve the caller's team. May be null on the synthetic + * empty snapshot returned to anonymous / team-less callers. + */ + teamId: number | null; + status: WalletStatus; + role: WalletRole; + /** + * ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed; + * the calendar month for free teams. + */ + billingPeriodStart: string; + billingPeriodEnd: string; + /** + * For a free team: the one-time free documents used so far ({@code + * freeAllowance − freeRemaining}). For a subscribed team: documents + * processed this month across automation + AI + API. + */ + billableUsed: number; + /** + * The team's document ceiling for the matching window: the one-time free + * grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap + * {@code floor(cap / perDocRate)} for capped subscribed teams; null when + * subscribed with no cap (uncapped). + */ + billableLimit: number | null; + /** + * The team's one-time free document grant size — the "N" in "X of N free". + * A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets + * and is not lost when the team subscribes. + */ + freeAllowance: number; + /** + * One-time free documents still available to the team + * ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted. + * Survives subscribing — a subscribed team keeps any unused grant. + */ + freeRemaining: number; + /** + * Paid per-document rate in minor units of {@link Wallet#currency} (may be + * fractional); null when the rate can't be resolved — render "unknown", + * never substitute. + */ + pricePerDocMinor: number | null; + /** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */ + currency: string | null; + /** + * Estimated charges so far this period in minor units of currency: paid + * (Stripe-metered) documents this period × rate. The free portion was + * already netted out at charge time. Informational — the Stripe invoice + * is authoritative. Null when the rate is unknown. + */ + estimatedBillMinor: number | null; + /** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */ + capUsd: number | null; + /** Only meaningful when status=='subscribed'. */ + noCap: boolean; + /** Stripe subscription id when subscribed; null when free. */ + stripeSubscriptionId: string | null; + /** Current-period spend in billable units. */ + spendUnitsThisPeriod: number; + /** Per-category spend breakdown (api / ai / automation). */ + categoryBreakdown: WalletCategoryBreakdown; + /** + * Team members, populated for the leader view; empty for members or + * single-seat tenants. Leader-vs-member is still resolved via {@link + * Wallet#role} — this field just carries the per-member rows the leader's + * sub-cap table needs. + */ + members: WalletMember[]; + /** + * Recent billable-activity rows. V1 returns {@code []} from the backend; + * the field exists so the Plan page can render an empty state without + * branching on undefined. Each entry is a {@code Record} + * because the activity-row shape is not yet finalised — when the meter- + * event surface lands, this widens to a real interface. + */ + recent: Array>; +} + +export interface UseWalletResult { + wallet: Wallet | null; + loading: boolean; + error: string | null; + /** Force a refetch — e.g. after Stripe redirects back into the app. */ + refetch: () => Promise; + /** + * Dev-only side-channel that simulates the Stripe webhook flipping the + * team to subscribed. Used by {@code UpgradeModal} when the backend is + * running the mock checkout — the real flow waits for the webhook + * instead and the next {@code refetch} picks up the change. Resolves + * once the post-mutation refetch completes. + */ + markSubscribed: (capUsd: number | null) => Promise; + /** + * Update the team's monthly cap. {@code null} means "no cap". Resolves + * once the post-mutation refetch completes so a save-button + * {@code loading} state can be safely cleared on resolution. + */ + updateCap: (capUsd: number | null) => Promise; + /** + * Mint a Stripe Customer Portal session and navigate to it. Calls + * the {@code create-customer-portal-session} Supabase edge function + * directly with the user's JWT (same pattern as checkout — no backend + * proxy; the function's RPC enforces team membership) and assigns + * {@code window.location} to the returned URL — same-tab redirect. + * We do not use {@code window.open(...,"_blank")} after an {@code await} + * because browsers treat it as non-user-gesture and silently popup-block + * it; the portal is a full-page experience anyway and Stripe redirects + * back to {@code return_url} on close. Throws on error so the caller can + * show a friendly toast — notably 404 {@code team_not_subscribed}. + */ + openPortal: () => Promise; +} + +// ─── Implementation ───────────────────────────────────────────────────── + +const STORAGE_KEY = "stirling.payg.devSubscription"; + +/** + * Stable reference reuse — if the new payload deep-equals the previous one, + * return the previous object so React's reference check short-circuits child + * renders. Walks the top-level scalars first (cheapest), then the nested + * {@code categoryBreakdown} object, then the {@code members} array. The + * {@code recent} array is identity-compared only — Wave 1 always returns + * {@code []} so a reference-stability check is sufficient; we'll deepen + * this once the activity surface lands. + */ +function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { + if (!prev) return next; + if ( + prev.status !== next.status || + prev.role !== next.role || + prev.billingPeriodStart !== next.billingPeriodStart || + prev.billingPeriodEnd !== next.billingPeriodEnd || + prev.billableUsed !== next.billableUsed || + prev.billableLimit !== next.billableLimit || + prev.freeAllowance !== next.freeAllowance || + prev.freeRemaining !== next.freeRemaining || + prev.pricePerDocMinor !== next.pricePerDocMinor || + prev.currency !== next.currency || + prev.estimatedBillMinor !== next.estimatedBillMinor || + prev.capUsd !== next.capUsd || + prev.noCap !== next.noCap || + prev.stripeSubscriptionId !== next.stripeSubscriptionId || + prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod + ) { + return next; + } + if (prev.recent.length !== next.recent.length) { + return next; + } + if ( + prev.categoryBreakdown.api !== next.categoryBreakdown.api || + prev.categoryBreakdown.ai !== next.categoryBreakdown.ai || + prev.categoryBreakdown.automation !== next.categoryBreakdown.automation + ) { + return next; + } + if (prev.members.length !== next.members.length) { + return next; + } + for (let i = 0; i < prev.members.length; i++) { + const a = prev.members[i]; + const b = next.members[i]; + if ( + a.userId !== b.userId || + a.name !== b.name || + a.email !== b.email || + a.spendUnits !== b.spendUnits + ) { + return next; + } + } + // recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical + // otherwise, so reuse the prior reference for stable child memoisation. + return prev; +} + +/** + * Synthesise a wallet snapshot for the dev preview route. Mirrors the same + * shape the backend returns. Subscription state comes from localStorage so + * the modal's "mark subscribed" action survives a reload. + */ +function buildDevPreviewWallet(role: WalletRole): Wallet { + const subscribed = + typeof window !== "undefined" && + (() => { + try { + return window.localStorage.getItem(STORAGE_KEY) === "subscribed"; + } catch { + return false; + } + })(); + + const now = new Date(); + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const isoDay = (d: Date) => d.toISOString().slice(0, 10); + + return { + teamId: null, + status: subscribed ? "subscribed" : "free", + role, + billingPeriodStart: isoDay(periodStart), + billingPeriodEnd: isoDay(periodEnd), + billableUsed: 62, + billableLimit: subscribed ? 1250 : 500, + freeAllowance: 500, + // One-time grant: a free team has used 62 of 500 (438 left); the dev + // subscribed team is shown with its grant fully spent (kept across the + // subscribe — it just no longer gates them). + freeRemaining: subscribed ? 0 : 438, + // Free teams also carry a rate now — the backend resolves it from the + // default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid + // PDFs/month") can render before subscribing. Mirror that here. + pricePerDocMinor: 2, + currency: "usd", + estimatedBillMinor: subscribed ? 0 : null, + capUsd: subscribed ? 25 : null, + noCap: false, + stripeSubscriptionId: subscribed ? "sub_devpreview" : null, + spendUnitsThisPeriod: 62, + // Wave 1 backend (PR #6574) returns a per-category breakdown so the + // hero panel can split AI / automation / API. Use realistic but + // tier-distinguishable mock values so the dev preview shows a + // different visual when the localStorage flip toggles subscribed. + categoryBreakdown: subscribed + ? { api: 12, ai: 35, automation: 15 } + : { api: 5, ai: 40, automation: 17 }, + // Members are populated in the leader view by the real backend + // (joining team_memberships); the dev preview returns an empty + // array — Plan.tsx + PaygLeader still resolve role via wallet.role, + // so empty members just hides the sub-caps card. + members: [], + // Activity feed is V1 = [], the backend ships this in Wave 2 once + // payg_meter_event_log is read-accessible from the wallet endpoint. + recent: [], + }; +} + +/** True when we're rendered outside the real saas app (e.g. dev preview route). */ +function isDevPreviewContext(): boolean { + // Both checks required: production builds drop the path check, so a real + // tenant whose URL begins with /dev/ can't accidentally hit the synthesised + // fallback. + if (!import.meta.env.DEV) return false; + if (typeof window === "undefined") return false; + return window.location.pathname.startsWith("/dev/"); +} + +/** Best-effort role read for dev preview — flips per query string ?role=member. */ +function devPreviewRole(): WalletRole { + if (typeof window === "undefined") return "leader"; + const url = new URL(window.location.href); + return url.searchParams.get("role") === "member" ? "member" : "leader"; +} + +export function useWallet(): UseWalletResult { + const devPreview = useRef(isDevPreviewContext()).current; + + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refetchTick, setRefetchTick] = useState(0); + + // Monotonic request id — used to discard stale responses if a faster + // refetch lands first. Only the latest issued id is permitted to commit + // its result. + const latestReqId = useRef(0); + + // Promise tracking the most recent in-flight load. Mutations await this + // so their resolution semantics are "the new state is visible," not + // "the request fired." Cleared when no load is pending. + const inFlight = useRef | null>(null); + + useEffect(() => { + const reqId = ++latestReqId.current; + let cancelled = false; + + const promise = (async () => { + setLoading(true); + setError(null); + + if (devPreview) { + const synth = buildDevPreviewWallet(devPreviewRole()); + if (cancelled || reqId !== latestReqId.current) return; + setWallet((prev) => reuseIfEqual(prev, synth)); + setLoading(false); + return; + } + + try { + const res = await apiClient.get("/api/v1/payg/wallet"); + if (cancelled || reqId !== latestReqId.current) return; + setWallet((prev) => reuseIfEqual(prev, res.data)); + } catch (e: unknown) { + if (cancelled || reqId !== latestReqId.current) return; + console.warn("[useWallet] fetch failed", e); + setError(e instanceof Error ? e.message : "Failed to load wallet"); + } finally { + if (!cancelled && reqId === latestReqId.current) { + setLoading(false); + } + } + })(); + + inFlight.current = promise; + + return () => { + cancelled = true; + // Don't clear inFlight here — let it resolve so mutations awaiting it + // still see a definitive "load completed" point. The reqId guard + // upstream ensures stale results don't commit. + }; + }, [devPreview, refetchTick]); + + const refetch = useCallback(async () => { + setRefetchTick((t) => t + 1); + // Snapshot the next-tick promise so the caller awaits this refetch + // specifically — the in-flight ref will be updated to it on the next + // effect run, but we can't reference that synchronously, so settle for + // a microtask handoff: await the *current* effect to flush, then await + // the new in-flight promise. + await Promise.resolve(); + if (inFlight.current) { + await inFlight.current; + } + }, []); + + const markSubscribed = useCallback( + async (capUsd: number | null) => { + if (devPreview) { + try { + window.localStorage.setItem(STORAGE_KEY, "subscribed"); + } catch { + /* storage unavailable */ + } + await refetch(); + return; + } + const noCap = capUsd === null; + // The dev side-channel only exists when the BE mock service is + // running (FE-branch local dev). Once the real backend (PR #6574) + // is in play, /dev/mark-subscribed is removed and the webhook + // (customer.subscription.created) is what flips the team to + // subscribed. We swallow 404s so the modal's completion path — + // which awaits this promise before rendering the confirmation + // screen — doesn't error out on a perfectly normal "the real + // backend doesn't expose this dev hook" response. A subsequent + // refetch picks up the webhook-driven flip whenever it lands. + try { + await apiClient.post("/api/v1/payg/dev/mark-subscribed", { + capUsd: capUsd ?? 0, + noCap, + }); + } catch (e: unknown) { + const status = + typeof e === "object" && e !== null && "response" in e + ? (e as { response?: { status?: number } }).response?.status + : undefined; + if (status === 404) { + // Real BE in play — webhook will land the subscription + // state; log and continue. Loud-but-harmless so the dev + // notices their /dev/mark-subscribed isn't wired up. + console.info( + "[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state", + ); + } else { + throw e; + } + } + await refetch(); + }, + [devPreview, refetch], + ); + + const updateCap = useCallback( + async (capUsd: number | null) => { + const noCap = capUsd === null; + if (devPreview) { + await refetch(); + return; + } + await apiClient.patch("/api/v1/payg/cap", { + capUsd: capUsd ?? 0, + noCap, + }); + await refetch(); + }, + [devPreview, refetch], + ); + + const openPortal = useCallback(async () => { + if (devPreview) { + // No real Stripe in dev preview — navigate to a placeholder so the + // click still feels alive. Same-tab to match the real-flow behaviour. + window.location.assign("https://billing.stripe.com/p/login/mock"); + return; + } + // Direct edge-fn invocation with the user's JWT — same pattern as + // create-checkout-session. The payg_get_checkout_context RPC inside the + // fn enforces team membership, so no backend proxy is needed; team_id + // must be a NUMBER (the fn type-checks and rejects strings). + const teamId = wallet?.teamId; + if (teamId == null) { + throw new Error("No team resolved yet"); + } + const { data, error: invokeError } = await supabase.functions.invoke<{ + url?: string; + error?: string; + }>("create-customer-portal-session", { + body: { team_id: teamId, return_url: window.location.href }, + }); + if (invokeError) { + // FunctionsHttpError etc. — the StripePortalLink caller catches and + // shows a friendly toast (404 team_not_subscribed, 403, outage). + throw invokeError; + } + if (!data?.url) { + throw new Error(data?.error ?? "Portal session response missing url"); + } + // Same-tab navigation rather than window.open(...,"_blank"): Stripe's + // customer portal is a full-page experience and brings the user back + // via return_url, so a popup buys us nothing — and window.open after + // an awaited promise is treated as non-user-gesture and silently + // popup-blocked by most browsers. + window.location.assign(data.url); + }, [devPreview, wallet?.teamId]); + + return { + wallet, + loading, + error, + refetch, + markSubscribed, + updateCap, + openPortal, + }; +} diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 893bda76f..8229c45ef 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -3,6 +3,10 @@ import { supabase } from "@app/auth/supabase"; import { handleHttpError } from "@app/services/httpErrorHandler"; import { alert } from "@app/components/toast"; import { openPlanSettings } from "@app/utils/appSettings"; +import { + classifyPaygError, + handlePaygError, +} from "@app/services/paygErrorInterceptor"; import { withBasePath } from "@app/constants/app"; // Global credit update callback - will be set by the AuthProvider @@ -159,12 +163,6 @@ apiClient.interceptors.response.use( ); } } - if (response.config?.url?.includes("/api/v1/credits")) { - console.debug( - "[API Client] Credits endpoint response headers:", - response.headers, - ); - } return response; }, async (error) => { @@ -174,6 +172,32 @@ apiClient.interceptors.response.use( originalRequest.url?.includes(endpoint), ); + // PAYG entitlement errors come from the EntitlementGuard on the server + // and have specific sentinels in the response body that we want to + // recognise *before* the generic 401/401-refresh logic kicks in: + // + // 402 FEATURE_DEGRADED — free-tier monthly cap exhausted; show a + // toast nudging the user to the Plan tab to upgrade. + // 401 SIGNUP_REQUIRED — anonymous user hit a billable endpoint; + // show a "Sign up to use [category]" modal instead of redirecting + // to /login (which is the default 401 behaviour). The user IS + // authenticated as anonymous — refreshing their session wouldn't + // unlock the endpoint, only signing up will. + // + // We classify the error here. If it matches either sentinel, we + // surface the appropriate UI and short-circuit the rest of the + // response interceptor so: + // - 401 SIGNUP_REQUIRED won't trigger the session-refresh / redirect- + // to-login dance below. + // - The handleHttpError() generic toast at the bottom won't fire. + // The error itself is still propagated to the caller so any + // component-level catch can react if needed. + const paygKind = classifyPaygError(error); + if (paygKind !== null) { + handlePaygError(paygKind, error); + return Promise.reject(error); + } + // On a first 401, refresh and retry — public endpoints included, since an // expired Bearer token is rejected on any route during cold load. if (status === 401 && !originalRequest._retry) { diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts new file mode 100644 index 000000000..84be4ecc9 --- /dev/null +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock the toast layer and openPlanSettings so we can assert what the +// handler dispatches without needing a real DOM context for the toast +// portal. Mocks are hoisted by vitest so the module under test imports +// these in place of the real implementations. +vi.mock("@app/components/toast", () => ({ + alert: vi.fn(), +})); +vi.mock("@app/utils/appSettings", () => ({ + openPlanSettings: vi.fn(), +})); + +import { alert } from "@app/components/toast"; +import { openPlanSettings } from "@app/utils/appSettings"; +import { + classifyPaygError, + extractSignupCategory, + handlePaygError, +} from "@app/services/paygErrorInterceptor"; + +describe("classifyPaygError", () => { + it("returns FEATURE_DEGRADED for 402 + error sentinel", () => { + const err = { + response: { + status: 402, + data: { + error: "FEATURE_DEGRADED", + missingGates: ["AUTOMATION"], + state: "DEGRADED", + periodEnd: "2026-06-30", + capUnits: 500, + spendUnits: 500, + }, + }, + }; + expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED"); + }); + + it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => { + const err = { + response: { + status: 401, + data: { error: "SIGNUP_REQUIRED", category: "AI" }, + }, + }; + expect(classifyPaygError(err)).toBe("SIGNUP_REQUIRED"); + }); + + it("returns null for plain 401 (session expired path is untouched)", () => { + const err = { + response: { + status: 401, + data: { error: "Unauthorized" }, + }, + }; + expect(classifyPaygError(err)).toBeNull(); + }); + + it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => { + const err = { + response: { status: 402, data: { error: "Payment required" } }, + }; + expect(classifyPaygError(err)).toBeNull(); + }); + + it("returns null when status mismatches sentinel (defence-in-depth)", () => { + // 500 + FEATURE_DEGRADED shouldn't match — server may have a bug and + // we don't want a generic 500 to silently degrade into an upgrade + // prompt. Same for 403 + SIGNUP_REQUIRED. + const a = { + response: { status: 500, data: { error: "FEATURE_DEGRADED" } }, + }; + const b = { + response: { status: 403, data: { error: "SIGNUP_REQUIRED" } }, + }; + expect(classifyPaygError(a)).toBeNull(); + expect(classifyPaygError(b)).toBeNull(); + }); + + it("returns null for malformed errors (null / non-object / no response)", () => { + expect(classifyPaygError(null)).toBeNull(); + expect(classifyPaygError(undefined)).toBeNull(); + expect(classifyPaygError("oops")).toBeNull(); + expect(classifyPaygError({})).toBeNull(); + expect(classifyPaygError({ response: null })).toBeNull(); + expect(classifyPaygError({ response: { status: 402 } })).toBeNull(); + expect( + classifyPaygError({ response: { status: 402, data: null } }), + ).toBeNull(); + expect( + classifyPaygError({ response: { status: 402, data: "bare-string" } }), + ).toBeNull(); + expect( + classifyPaygError({ response: { status: 402, data: { error: 123 } } }), + ).toBeNull(); + }); +}); + +describe("extractSignupCategory", () => { + it("returns the category string when present", () => { + expect( + extractSignupCategory({ + response: { data: { error: "SIGNUP_REQUIRED", category: "AI" } }, + }), + ).toBe("AI"); + }); + + it("returns null when missing or wrong type", () => { + expect(extractSignupCategory(null)).toBeNull(); + expect(extractSignupCategory({})).toBeNull(); + expect( + extractSignupCategory({ response: { data: { category: 7 } } }), + ).toBeNull(); + expect( + extractSignupCategory({ response: { data: {} } }), + ).toBeNull(); + }); +}); + +describe("handlePaygError", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => { + handlePaygError("FEATURE_DEGRADED", { + response: { status: 402, data: { error: "FEATURE_DEGRADED" } }, + }); + expect(alert).toHaveBeenCalledTimes(1); + const opts = vi.mocked(alert).mock.calls[0][0]; + expect(opts.alertType).toBe("warning"); + expect(opts.isPersistentPopup).toBe(true); + expect(opts.buttonText).toBe("Go to billing"); + // Body should reference the 500-op free monthly allowance so the + // user understands what they hit. + expect(String(opts.body)).toMatch(/500/); + }); + + it("invoking the toast's buttonCallback opens the Plan settings tab", () => { + handlePaygError("FEATURE_DEGRADED", { + response: { status: 402, data: { error: "FEATURE_DEGRADED" } }, + }); + const opts = vi.mocked(alert).mock.calls[0][0]; + expect(opts.buttonCallback).toBeDefined(); + opts.buttonCallback?.(); + expect(openPlanSettings).toHaveBeenCalledTimes(1); + }); + + it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => { + const handler = vi.fn(); + window.addEventListener("payg:signupRequired", handler); + try { + handlePaygError("SIGNUP_REQUIRED", { + response: { + status: 401, + data: { error: "SIGNUP_REQUIRED", category: "AUTOMATION" }, + }, + }); + expect(handler).toHaveBeenCalledTimes(1); + const ev = handler.mock.calls[0][0] as CustomEvent; + expect(ev.detail).toEqual({ category: "AUTOMATION" }); + // No toast for SIGNUP_REQUIRED — the modal carries the message. + expect(alert).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("payg:signupRequired", handler); + } + }); + + it("dispatches with category=null when the body has no category", () => { + const handler = vi.fn(); + window.addEventListener("payg:signupRequired", handler); + try { + handlePaygError("SIGNUP_REQUIRED", { + response: { status: 401, data: { error: "SIGNUP_REQUIRED" } }, + }); + expect(handler).toHaveBeenCalledTimes(1); + const ev = handler.mock.calls[0][0] as CustomEvent; + expect(ev.detail).toEqual({ category: null }); + } finally { + window.removeEventListener("payg:signupRequired", handler); + } + }); +}); diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.ts new file mode 100644 index 000000000..76ae0f4f3 --- /dev/null +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.ts @@ -0,0 +1,131 @@ +/** + * Classifies and reacts to PAYG-specific error responses surfaced by the + * backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels + * are recognised: + * + *
    + *
  • {@code 402 FEATURE_DEGRADED} — free-tier user has burned through + * their 500-op monthly allowance. Surface a toast that nudges them to + * the Plan tab so they can upgrade.
  • + *
  • {@code 401 SIGNUP_REQUIRED} — anonymous (guest) user hit a billable + * endpoint. Open a modal explaining why they need a real account and + * where their 500-op free monthly allowance comes in. The body's + * {@code category} field ({@code AI}, {@code AUTOMATION}, {@code API}) + * feeds the modal title so the user understands *which* feature they + * just hit. We dispatch a {@code CustomEvent} rather than rendering + * directly from this module because the apiClient is created outside + * the React tree and can't import JSX; the listener lives on a + * bootstrap component mounted near the app root.
  • + *
+ * + * The classifier is exported separately from the handler so unit tests can + * exercise the parsing logic without touching the toast / event side + * effects. + */ +import { alert } from "@app/components/toast"; +import i18n from "@app/i18n"; +import { openPlanSettings } from "@app/utils/appSettings"; + +/** + * Possible PAYG entitlement sentinels the EntitlementGuard returns. + * {@code null} when the error is not a PAYG entitlement response. + */ +export type PaygErrorKind = "FEATURE_DEGRADED" | "SIGNUP_REQUIRED"; + +/** + * Detail payload broadcast on {@code payg:signupRequired} when an anonymous + * user hits a billable endpoint. The listener (a Bootstrap component near + * the app root) opens a modal whose copy is parameterised by + * {@link #category}. + */ +export interface PaygSignupRequiredDetail { + /** Category that triggered the gate — {@code AI}, {@code AUTOMATION}, or {@code API}. */ + category: string | null; +} + +/** + * Inspect an axios-style error and decide whether it's one of the known + * PAYG sentinels. Returns the kind, or {@code null} if it isn't. + * + * The check is intentionally strict (status code AND body.error sentinel) + * so we don't hijack incidental 401/402 responses from other endpoints — + * notably the existing session-expired 401 flow. + */ +export function classifyPaygError(error: unknown): PaygErrorKind | null { + if (!error || typeof error !== "object") return null; + const response = (error as { response?: unknown }).response; + if (!response || typeof response !== "object") return null; + const status = (response as { status?: unknown }).status; + const data = (response as { data?: unknown }).data; + if (typeof status !== "number") return null; + if (!data || typeof data !== "object") return null; + const sentinel = (data as { error?: unknown }).error; + if (typeof sentinel !== "string") return null; + + if (status === 402 && sentinel === "FEATURE_DEGRADED") { + return "FEATURE_DEGRADED"; + } + if (status === 401 && sentinel === "SIGNUP_REQUIRED") { + return "SIGNUP_REQUIRED"; + } + return null; +} + +/** Extract {@code data.category} (a string) from an axios error, or {@code null}. */ +export function extractSignupCategory(error: unknown): string | null { + if (!error || typeof error !== "object") return null; + const response = (error as { response?: unknown }).response; + if (!response || typeof response !== "object") return null; + const data = (response as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const category = (data as { category?: unknown }).category; + return typeof category === "string" ? category : null; +} + +/** + * Surface the appropriate UI for a classified PAYG error. Toast for + * {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}. + * + * Idempotent / safe to call multiple times — the toast layer coalesces + * duplicates by (alertType, title, body) and the modal listener already + * dedupes by its own opened-state. Suppress-respecting: if the caller + * passed {@code suppressErrorToast: true} on the axios config (the + * established pattern for component-level error handling), we still fire + * the PAYG UI because these are user-facing gates, not transient + * error toasts — the suppression flag was for the *generic* error toast, + * which we're replacing with something more actionable. + */ +export function handlePaygError(kind: PaygErrorKind, error: unknown): void { + if (kind === "FEATURE_DEGRADED") { + alert({ + alertType: "warning", + title: i18n.t( + "payg.exhausted.title", + "You've hit your free monthly limit", + ), + body: i18n.t( + "payg.exhausted.body", + "You've used your free 500 operations this month. Upgrade to Processor to keep going.", + ), + buttonText: i18n.t("payg.exhausted.cta", "Go to billing"), + buttonCallback: () => openPlanSettings(), + isPersistentPopup: true, + location: "bottom-right", + }); + return; + } + + if (kind === "SIGNUP_REQUIRED") { + const category = extractSignupCategory(error); + try { + window.dispatchEvent( + new CustomEvent("payg:signupRequired", { + detail: { category }, + }), + ); + } catch { + // SSR / test environments without a real window — no-op. + } + return; + } +} From 606964ee52a6eb39efe64f6fe3ce4c8b24ef5969 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 11 Jun 2026 16:26:02 +0100 Subject: [PATCH 35/80] Fix Teams and MCP settings pages (#6605) # Description of Changes Remove the Pro guards from the Team settings page and also fix the styling of the MCP settings screen (the code sections were black text on black background in light mode) --- .../public/locales/en-GB/translation.toml | 28 --- .../editor/src/core/theme/mantineTheme.ts | 10 + .../configSections/SaaSTeamsSection.tsx | 171 +----------------- .../config/configSections/TeamSection.tsx | 169 +---------------- 4 files changed, 14 insertions(+), 364 deletions(-) diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index e0745cad5..86cbf5873 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2761,7 +2761,6 @@ copy = "Copy" done = "Done" error = "Error" expand = "Expand" -learnMore = "Learn more" loading = "Loading..." next = "Next" operation = "this operation" @@ -7503,28 +7502,6 @@ removeError = "Failed to remove member" renameError = "Failed to rename team" renameSuccess = "Team renamed successfully" -[team.features] -badge = "Team Features" -subtitle = "Collaborate with your team" -title = "Team Collaboration" -viewPlans = "View Plans" - -[team.features.billing] -description = "Manage billing and subscriptions" -title = "Billing Management" - -[team.features.credits] -description = "Shared credit pool for all members" -title = "Shared Credits" - -[team.features.dashboard] -description = "View team activity and usage" -title = "Team Dashboard" - -[team.features.invite] -description = "Add members to your team" -title = "Invite Members" - [team.invitationBanner] acceptButton = "Accept" message = "has invited you to join" @@ -7546,11 +7523,6 @@ remove = "Remove" roleColumn = "Role" title = "Team Members" -[team.upgrade] -button = "Upgrade to Team" -description = "Unlock team collaboration features" -title = "Upgrade to Team Plan" - [textAlign] center = "Center" left = "Left" diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index 9bc66d942..061ac0e81 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -150,6 +150,16 @@ export const mantineTheme = createTheme({ }, }, }, + + Code: { + styles: { + root: { + backgroundColor: "var(--color-gray-100)", + color: "var(--text-primary)", + }, + }, + }, + Textarea: { styles: (_theme: MantineTheme) => ({ input: { diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx index b172f7128..74f03b51a 100644 --- a/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configSections/SaaSTeamsSection.tsx @@ -10,15 +10,9 @@ import { Badge, ActionIcon, Menu, - List, - ThemeIcon, - Modal, - CloseButton, - Anchor, } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import { useSaaSBilling } from "@app/contexts/SaasBillingContext"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; import apiClient from "@app/services/apiClient"; @@ -43,15 +37,10 @@ export function SaaSTeamsSection() { refreshTeams, } = useSaaSTeam(); - // Check Pro status via billing context - const { tier } = useSaaSBilling(); - const isPro = tier !== "free"; - const [inviteEmail, setInviteEmail] = useState(""); const [inviting, setInviting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [featuresModalOpened, setFeaturesModalOpened] = useState(false); // Team rename state const [isEditingName, setIsEditingName] = useState(false); @@ -71,12 +60,6 @@ export function SaaSTeamsSection() { return () => clearInterval(interval); }, []); // Only run on mount/unmount - const navigateToPlan = () => { - window.dispatchEvent( - new CustomEvent("appConfig:navigate", { detail: { key: "planBilling" } }), - ); - }; - const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!inviteEmail.trim()) return; @@ -321,156 +304,6 @@ export function SaaSTeamsSection() {
- {/* Upgrade Banner for Free Users */} - {isPersonalTeam && !isPro && ( - } - > - -
- - {t( - "team.upgrade.title", - "Upgrade to Pro to unlock team features", - )} - - - {t( - "team.upgrade.description", - "Invite members, share credits, and more.", - )}{" "} - setFeaturesModalOpened(true)} - style={{ cursor: "pointer" }} - > - {t("common.learnMore", "Learn more")} - - -
- -
-
- )} - - {/* Team Features Modal */} - setFeaturesModalOpened(false)} - size="md" - centered - padding="xl" - withCloseButton={false} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > -
- setFeaturesModalOpened(false)} - size="lg" - style={{ - position: "absolute", - top: -8, - right: -8, - zIndex: 1, - }} - /> - - {/* Header */} - - - {t("team.features.badge", "PRO FEATURE")} - - - {t("team.features.title", "Team Collaboration")} - - - {t( - "team.features.subtitle", - "Upgrade to Pro and unlock powerful team features", - )} - - - - {/* Features List */} - - - - } - > - - - {t("team.features.invite.title", "Invite team members")} - - - {t( - "team.features.invite.description", - "Add unlimited users with additional seat purchases", - )} - - - - - {t( - "team.features.credits.title", - "Share credits across your team", - )} - - - {t( - "team.features.credits.description", - "Pool resources for collaborative work", - )} - - - - - {t( - "team.features.dashboard.title", - "Team management dashboard", - )} - - - {t( - "team.features.dashboard.description", - "Control permissions, monitor usage, and manage members", - )} - - - - - {t("team.features.billing.title", "Centralized billing")} - - - {t( - "team.features.billing.description", - "One invoice for all team seats and usage", - )} - - - - - {/* CTA Button */} - - -
-
- {/* Error/Success Messages */} {error && ( setError(null)} withCloseButton> @@ -484,8 +317,8 @@ export function SaaSTeamsSection() { )} - {/* Invite Members (Pro Users) */} - {isTeamLeader && isPro && ( + {/* Invite Members */} + {isTeamLeader && (
{t("team.invite.title", "Invite Team Member")} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx index 22e20ff77..6755a4d74 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/TeamSection.tsx @@ -10,15 +10,9 @@ import { Badge, ActionIcon, Menu, - List, - ThemeIcon, - Modal, - CloseButton, - Anchor, } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; -import { useAuth } from "@app/auth/UseSession"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; import apiClient from "@app/services/apiClient"; @@ -38,13 +32,10 @@ const TeamSection: React.FC = () => { refreshTeams, } = useSaaSTeam(); - const { isPro } = useAuth(); - const [inviteEmail, setInviteEmail] = useState(""); const [inviting, setInviting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [featuresModalOpened, setFeaturesModalOpened] = useState(false); // Team rename state const [isEditingName, setIsEditingName] = useState(false); @@ -64,12 +55,6 @@ const TeamSection: React.FC = () => { return () => clearInterval(interval); }, []); // Only run on mount/unmount - const navigateToPlan = () => { - window.dispatchEvent( - new CustomEvent("appConfig:navigate", { detail: { key: "plan" } }), - ); - }; - const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); if (!inviteEmail.trim()) return; @@ -314,156 +299,6 @@ const TeamSection: React.FC = () => {
- {/* Upgrade Banner for Free Users */} - {isPersonalTeam && !isPro && ( - } - > - -
- - {t( - "team.upgrade.title", - "Upgrade to Pro to unlock team features", - )} - - - {t( - "team.upgrade.description", - "Invite members, share credits, and more.", - )}{" "} - setFeaturesModalOpened(true)} - style={{ cursor: "pointer" }} - > - {t("common.learnMore", "Learn more")} - - -
- -
-
- )} - - {/* Team Features Modal */} - setFeaturesModalOpened(false)} - size="md" - centered - padding="xl" - withCloseButton={false} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - > -
- setFeaturesModalOpened(false)} - size="lg" - style={{ - position: "absolute", - top: -8, - right: -8, - zIndex: 1, - }} - /> - - {/* Header */} - - - {t("team.features.badge", "PRO FEATURE")} - - - {t("team.features.title", "Team Collaboration")} - - - {t( - "team.features.subtitle", - "Upgrade to Pro and unlock powerful team features", - )} - - - - {/* Features List */} - - - - } - > - - - {t("team.features.invite.title", "Invite team members")} - - - {t( - "team.features.invite.description", - "Add unlimited users with additional seat purchases", - )} - - - - - {t( - "team.features.credits.title", - "Share credits across your team", - )} - - - {t( - "team.features.credits.description", - "Pool resources for collaborative work", - )} - - - - - {t( - "team.features.dashboard.title", - "Team management dashboard", - )} - - - {t( - "team.features.dashboard.description", - "Control permissions, monitor usage, and manage members", - )} - - - - - {t("team.features.billing.title", "Centralized billing")} - - - {t( - "team.features.billing.description", - "One invoice for all team seats and usage", - )} - - - - - {/* CTA Button */} - - -
-
- {/* Error/Success Messages */} {error && ( setError(null)} withCloseButton> @@ -477,8 +312,8 @@ const TeamSection: React.FC = () => { )} - {/* Invite Members (Pro Users) */} - {isTeamLeader && isPro && ( + {/* Invite Members */} + {isTeamLeader && (
{t("team.invite.title", "Invite Team Member")} From d52c7ced7cb7b2078a4312a7671451522708ba6f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 11 Jun 2026 16:31:35 +0100 Subject: [PATCH 36/80] Improvements to Stirling Engine to prepare for SaaS release (#6603) # Description of Changes - Use pool for postgres connections - Add ability to require user ID to be set on API calls to the engine - Add process-wide concurrency cap on AI access (in addition to existing user caps) - Allow number of workers (threads) to be specified for stirling engine - Update env var names to reflect that the DB is not just for RAG --- .taskfiles/engine.yml | 2 +- engine/.env | 30 ++++-- engine/Dockerfile | 1 + engine/pyproject.toml | 2 +- engine/src/stirling/api/app.py | 22 +++-- engine/src/stirling/api/dependencies.py | 18 +++- engine/src/stirling/config/__init__.py | 4 +- engine/src/stirling/config/settings.py | 26 ++++- engine/src/stirling/documents/README.md | 8 +- .../src/stirling/documents/pgvector_store.py | 95 ++++++++++++------- engine/src/stirling/services/runtime.py | 64 +++++++++++-- engine/tests/conftest.py | 12 ++- engine/tests/pdf_comment/test_routes.py | 12 ++- engine/tests/test_model_concurrency.py | 58 +++++++++++ engine/tests/test_stirling_api.py | 20 ++++ engine/tests/test_stirling_contracts.py | 12 ++- engine/uv.lock | 19 +++- 17 files changed, 315 insertions(+), 90 deletions(-) create mode 100644 engine/tests/test_model_concurrency.py diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index 1834495b1..244b48353 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -43,7 +43,7 @@ tasks: env: PYTHONUNBUFFERED: "1" cmds: - - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} + - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --workers "${STIRLING_ENGINE_WORKERS:-4}" dev: desc: "Start engine dev server with hot reload" diff --git a/engine/.env b/engine/.env index e334a9bf9..a99abf0cc 100644 --- a/engine/.env +++ b/engine/.env @@ -14,19 +14,30 @@ STIRLING_FAST_MODEL=anthropic:claude-haiku-4-5 STIRLING_SMART_MODEL_MAX_TOKENS=8192 STIRLING_FAST_MODEL_MAX_TOKENS=2048 -# RAG Configuration — retrieval-augmented generation is always on. -# Embedding provider credentials are handled natively (e.g. VOYAGE_API_KEY for VoyageAI). -STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 +# Process-wide cap on concurrent model API calls, shared by both model tiers. +# Per-request fan-outs (chunked reasoner workers, contradiction detection) are +# bounded per request; this bounds their product across concurrent requests. +STIRLING_MODEL_MAX_CONCURRENCY=32 -# Vector store backend: "sqlite" (embedded) or "pgvector" (external Postgres). -STIRLING_RAG_BACKEND=sqlite +# Document store: the one database holding vector chunks, ordered page text, +# and ACL rows. Backend is "sqlite" (embedded sqlite-vec) or "pgvector" +# (external Postgres). +STIRLING_DOCUMENTS_BACKEND=sqlite # Path to the sqlite-vec database file (used when backend=sqlite). -STIRLING_RAG_STORE_PATH=data/rag.db +STIRLING_DOCUMENTS_SQLITE_PATH=data/rag.db # Postgres DSN for pgvector (used when backend=pgvector). Leave empty when backend=sqlite. # Example: postgresql://user:password@host:5432/dbname -STIRLING_RAG_PGVECTOR_DSN= +STIRLING_DOCUMENTS_PGVECTOR_DSN= + +# Connection pool bounds for the pgvector backend. +STIRLING_DOCUMENTS_PGVECTOR_POOL_MIN_SIZE=1 +STIRLING_DOCUMENTS_PGVECTOR_POOL_MAX_SIZE=10 + +# RAG Configuration - retrieval-augmented generation is always on. +# Embedding provider credentials are handled natively (e.g. VOYAGE_API_KEY for VoyageAI). +STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 STIRLING_RAG_CHUNK_SIZE=512 STIRLING_RAG_CHUNK_OVERLAP=64 @@ -57,6 +68,11 @@ STIRLING_CHUNKED_REASONER_NOTES_CHAR_BUDGET=250000 STIRLING_MAX_PAGES=200 STIRLING_MAX_CHARACTERS=200000 +# Reject API requests that lack an X-User-Id header. Self-hosted deployments +# with security disabled have no user identity, so this is off by default. +# Multi-tenant (SaaS) deployments must set it to true. +STIRLING_REQUIRE_USER_ID=false + # PostHog analytics. Set STIRLING_POSTHOG_ENABLED=true and provide an API key to enable. STIRLING_POSTHOG_ENABLED=false STIRLING_POSTHOG_API_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz diff --git a/engine/Dockerfile b/engine/Dockerfile index 5a0a835fa..e3f548e8d 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -26,6 +26,7 @@ COPY .taskfiles/ ./.taskfiles/ ENV PATH="/app/engine/.venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 +ENV STIRLING_ENGINE_WORKERS=4 EXPOSE 5001 diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 4074c21ee..7bd2fa7be 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -6,7 +6,7 @@ requires-python = ">=3.13" dependencies = [ "fastapi>=0.116.0", "pgvector>=0.3.6", - "psycopg[binary]>=3.2", + "psycopg[binary,pool]>=3.2", "pydantic>=2.0.0", "pydantic-ai>=1.67.0", "pydantic-ai-slim[voyageai]>=1.67.0", diff --git a/engine/src/stirling/api/app.py b/engine/src/stirling/api/app.py index 699cede06..7e6272c28 100644 --- a/engine/src/stirling/api/app.py +++ b/engine/src/stirling/api/app.py @@ -18,6 +18,7 @@ from stirling.agents import ( ) from stirling.agents.ledger import MathAuditorAgent from stirling.agents.pdf_comment import PdfCommentAgent +from stirling.api.dependencies import enforce_required_user_id from stirling.api.engine_auth import EngineSharedSecretMiddleware from stirling.api.middleware import UserIdMiddleware from stirling.api.routes import ( @@ -118,15 +119,18 @@ async def lifespan(fast_api: FastAPI): app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0") app.add_middleware(UserIdMiddleware) app.add_middleware(EngineSharedSecretMiddleware) -app.include_router(orchestrator_router) -app.include_router(pdf_edit_router) -app.include_router(pdf_question_router) -app.include_router(agent_draft_router) -app.include_router(execution_router) -app.include_router(document_router) -app.include_router(ledger_router) -app.include_router(pdf_comments_router) -app.include_router(agent_capabilities_router) +# Every router gets the same configurable identity gate; /health stays open +# for liveness probes. See enforce_required_user_id for the policy. +_user_gate = [Depends(enforce_required_user_id)] +app.include_router(orchestrator_router, dependencies=_user_gate) +app.include_router(pdf_edit_router, dependencies=_user_gate) +app.include_router(pdf_question_router, dependencies=_user_gate) +app.include_router(agent_draft_router, dependencies=_user_gate) +app.include_router(execution_router, dependencies=_user_gate) +app.include_router(document_router, dependencies=_user_gate) +app.include_router(ledger_router, dependencies=_user_gate) +app.include_router(pdf_comments_router, dependencies=_user_gate) +app.include_router(agent_capabilities_router, dependencies=_user_gate) @app.get("/health", response_model=HealthResponse) diff --git a/engine/src/stirling/api/dependencies.py b/engine/src/stirling/api/dependencies.py index cae20a8ae..780e97c9d 100644 --- a/engine/src/stirling/api/dependencies.py +++ b/engine/src/stirling/api/dependencies.py @@ -1,6 +1,8 @@ from __future__ import annotations -from fastapi import HTTPException, Request, status +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status from stirling.agents import ( ExecutionPlanningAgent, @@ -11,6 +13,7 @@ from stirling.agents import ( ) from stirling.agents.ledger import MathAuditorAgent from stirling.agents.pdf_comment import PdfCommentAgent +from stirling.config import AppSettings, load_settings from stirling.documents import DocumentService from stirling.models import UserId from stirling.services import AppRuntime, current_user_id @@ -67,3 +70,16 @@ def require_user_id() -> UserId: detail="X-User-Id header is required", ) return user_id + + +def enforce_required_user_id( + settings: Annotated[AppSettings, Depends(load_settings)], +) -> None: + """Router-level boundary gate, applied uniformly to every router.""" + if not settings.require_user_id: + return + if current_user_id.get() is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-User-Id header is required", + ) diff --git a/engine/src/stirling/config/__init__.py b/engine/src/stirling/config/__init__.py index c5044a214..f038d02e5 100644 --- a/engine/src/stirling/config/__init__.py +++ b/engine/src/stirling/config/__init__.py @@ -1,10 +1,10 @@ """Configuration models and loaders for the Stirling AI service.""" -from .settings import ENGINE_ROOT, AppSettings, RagBackend, load_settings +from .settings import ENGINE_ROOT, AppSettings, DocumentsBackend, load_settings __all__ = [ "ENGINE_ROOT", "AppSettings", - "RagBackend", + "DocumentsBackend", "load_settings", ] diff --git a/engine/src/stirling/config/settings.py b/engine/src/stirling/config/settings.py index 4cd2bbc9e..1293cc371 100644 --- a/engine/src/stirling/config/settings.py +++ b/engine/src/stirling/config/settings.py @@ -15,7 +15,7 @@ ENV_FILE = ENGINE_ROOT / ".env" ENV_LOCAL_FILE = ENGINE_ROOT / ".env.local" -class RagBackend(StrEnum): +class DocumentsBackend(StrEnum): SQLITE = "sqlite" PGVECTOR = "pgvector" @@ -27,12 +27,22 @@ class AppSettings(BaseSettings): fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL") smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS") fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS") + # Process-wide ceiling on concurrent model API calls, shared by both model + # tiers. Per-request fan-outs (chunked reasoner, contradiction detection) + # carry their own per-request caps, but those multiply under concurrent + # traffic; this is the global backstop. + model_max_concurrency: int = Field(validation_alias="STIRLING_MODEL_MAX_CONCURRENCY") - # RAG settings — always on; the backend picks between embedded sqlite-vec and external pgvector. - rag_backend: RagBackend = Field(validation_alias="STIRLING_RAG_BACKEND") + # Document store: the one database holding vector chunks, ordered page + # text, and ACL rows - embedded sqlite-vec or external pgvector. + documents_backend: DocumentsBackend = Field(validation_alias="STIRLING_DOCUMENTS_BACKEND") + documents_sqlite_path: Path = Field(validation_alias="STIRLING_DOCUMENTS_SQLITE_PATH") + documents_pgvector_dsn: str = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_DSN") + documents_pgvector_pool_min_size: int = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_POOL_MIN_SIZE") + documents_pgvector_pool_max_size: int = Field(validation_alias="STIRLING_DOCUMENTS_PGVECTOR_POOL_MAX_SIZE") + + # RAG settings - always on. rag_embedding_model: str = Field(validation_alias="STIRLING_RAG_EMBEDDING_MODEL") - rag_store_path: Path = Field(validation_alias="STIRLING_RAG_STORE_PATH") - rag_pgvector_dsn: str = Field(validation_alias="STIRLING_RAG_PGVECTOR_DSN") rag_chunk_size: int = Field(validation_alias="STIRLING_RAG_CHUNK_SIZE") rag_chunk_overlap: int = Field(validation_alias="STIRLING_RAG_CHUNK_OVERLAP") rag_default_top_k: int = Field(validation_alias="STIRLING_RAG_TOP_K") @@ -88,6 +98,12 @@ class AppSettings(BaseSettings): max_pages: int = Field(validation_alias="STIRLING_MAX_PAGES") max_characters: int = Field(validation_alias="STIRLING_MAX_CHARACTERS") + # When true, API routes reject requests that lack an X-User-Id header at + # the boundary. Self-hosted deployments with security disabled have no + # user identity and leave this off; multi-tenant deployments turn it on so + # user-scoped work is never processed without a tenant attached. + require_user_id: bool = Field(validation_alias="STIRLING_REQUIRE_USER_ID") + log_level: str = Field(default="INFO", validation_alias="STIRLING_LOG_LEVEL") log_file: str = Field(default="", validation_alias="STIRLING_LOG_FILE") # When true, raises httpx + httpcore logger levels so every outgoing diff --git a/engine/src/stirling/documents/README.md b/engine/src/stirling/documents/README.md index e1c759044..6848d8a10 100644 --- a/engine/src/stirling/documents/README.md +++ b/engine/src/stirling/documents/README.md @@ -54,10 +54,10 @@ everything = RagCapability(runtime.documents) Non-secret defaults live in the committed `engine/.env`: ``` -STIRLING_RAG_BACKEND=sqlite # or "pgvector" +STIRLING_DOCUMENTS_BACKEND=sqlite # or "pgvector" STIRLING_RAG_EMBEDDING_MODEL=voyageai:voyage-4 -STIRLING_RAG_STORE_PATH=data/rag.db # used when backend=sqlite -STIRLING_RAG_PGVECTOR_DSN= # used when backend=pgvector +STIRLING_DOCUMENTS_SQLITE_PATH=data/rag.db # used when backend=sqlite +STIRLING_DOCUMENTS_PGVECTOR_DSN= # used when backend=pgvector STIRLING_RAG_CHUNK_SIZE=512 STIRLING_RAG_CHUNK_OVERLAP=64 STIRLING_RAG_TOP_K=5 @@ -76,7 +76,7 @@ VOYAGE_API_KEY=your-key and self-hosted deployments. **`pgvector`** - External PostgreSQL with the `vector` extension. Point -`STIRLING_RAG_PGVECTOR_DSN` at your Postgres instance. +`STIRLING_DOCUMENTS_PGVECTOR_DSN` at your Postgres instance. Both backends implement the same `DocumentStore` interface, so agents and the service work identically regardless of which you pick. diff --git a/engine/src/stirling/documents/pgvector_store.py b/engine/src/stirling/documents/pgvector_store.py index 8aff5ad3a..1599ae047 100644 --- a/engine/src/stirling/documents/pgvector_store.py +++ b/engine/src/stirling/documents/pgvector_store.py @@ -1,10 +1,12 @@ from __future__ import annotations +import asyncio import json from datetime import datetime import psycopg from pgvector.psycopg import register_vector_async +from psycopg_pool import AsyncConnectionPool from stirling.contracts.documents import Page, PageRange from stirling.documents.store import Document, DocumentStore, SearchResult, StoredPage @@ -13,12 +15,20 @@ from stirling.models import OwnerId, PrincipalId _READ_PERMISSION = "read" +async def _register_vector(conn: psycopg.AsyncConnection) -> None: + await register_vector_async(conn) + + class PgVectorStore(DocumentStore): """PostgreSQL + pgvector backed store, scoped by owner with ACL-gated reads. Connects to an external Postgres instance (DSN provided via config) and uses the `vector` extension for similarity search. The schema is created on first use. + Queries run on a shared async connection pool. The pool is opened lazily on + first use, after the schema bootstrap, because each pooled connection's + configure hook registers the ``vector`` type, which must already exist. + Owned tables: * ``documents_meta`` - parent row, one per ``(collection, owner_id)``. @@ -31,21 +41,36 @@ class PgVectorStore(DocumentStore): Writes are owner-scoped; reads are ACL-scoped. """ - def __init__(self, dsn: str) -> None: + def __init__(self, dsn: str, pool_min_size: int, pool_max_size: int) -> None: if not dsn: - raise ValueError("pgvector backend requires a non-empty DSN (STIRLING_RAG_PGVECTOR_DSN)") + raise ValueError("pgvector backend requires a non-empty DSN (STIRLING_DOCUMENTS_PGVECTOR_DSN)") self._dsn = dsn + self._pool = AsyncConnectionPool( + dsn, + min_size=pool_min_size, + max_size=pool_max_size, + open=False, + configure=_register_vector, + check=AsyncConnectionPool.check_connection, + ) self._initialized = False + self._init_lock = asyncio.Lock() - async def _connect(self) -> psycopg.AsyncConnection: - conn = await psycopg.AsyncConnection.connect(self._dsn) - await register_vector_async(conn) - return conn - - async def _ensure_schema(self) -> None: + async def _ensure_ready(self) -> None: + """Create the schema and open the pool, exactly once across concurrent callers.""" if self._initialized: return - async with await self._connect() as conn: + async with self._init_lock: + if self._initialized: + return + await self._bootstrap_schema() + await self._pool.open() + self._initialized = True + + async def _bootstrap_schema(self) -> None: + # Uses a dedicated non-pool connection: pooled connections register the + # vector type on checkout, which fails until the extension exists. + async with await psycopg.AsyncConnection.connect(self._dsn) as conn: async with conn.cursor() as cur: await cur.execute("CREATE EXTENSION IF NOT EXISTS vector") await cur.execute( @@ -118,7 +143,6 @@ class PgVectorStore(DocumentStore): "CREATE INDEX IF NOT EXISTS idx_acl_principal_permission ON document_acl(principal_id, permission)" ) await conn.commit() - self._initialized = True # ── lifecycle of the (collection, owner_id) row ──────────────────────── @@ -129,8 +153,8 @@ class PgVectorStore(DocumentStore): owner_id: OwnerId, expires_at: datetime | None, ) -> None: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute( """ @@ -145,8 +169,8 @@ class PgVectorStore(DocumentStore): await conn.commit() async def purge_owner(self, owner_id: OwnerId) -> int: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM documents_meta WHERE owner_id = %s", @@ -157,8 +181,8 @@ class PgVectorStore(DocumentStore): return deleted async def reap_expired(self) -> int: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute("DELETE FROM documents_meta WHERE expires_at IS NOT NULL AND expires_at < NOW()") deleted = cur.rowcount @@ -166,8 +190,8 @@ class PgVectorStore(DocumentStore): return deleted async def delete_collection(self, collection: str, owner_id: OwnerId) -> bool: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: # Cascade FKs handle rag_documents, document_pages, document_acl. await cur.execute( @@ -192,8 +216,8 @@ class PgVectorStore(DocumentStore): if not documents: return - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: for doc, emb in zip(documents, embeddings): await cur.execute( @@ -211,8 +235,8 @@ class PgVectorStore(DocumentStore): await conn.commit() async def add_pages(self, collection: str, pages: list[StoredPage], owner_id: OwnerId) -> None: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM document_pages WHERE collection = %s AND owner_id = %s", @@ -238,8 +262,8 @@ class PgVectorStore(DocumentStore): ) -> None: if not principals: return - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.executemany( """ @@ -257,8 +281,8 @@ class PgVectorStore(DocumentStore): owner_id: OwnerId, principal: PrincipalId, ) -> None: - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM document_acl WHERE collection = %s AND owner_id = %s AND principal_id = %s", @@ -277,8 +301,8 @@ class PgVectorStore(DocumentStore): ) -> list[SearchResult]: if not principals: return [] - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) if owner_id is None: @@ -332,8 +356,8 @@ class PgVectorStore(DocumentStore): ) -> list[Page]: if not principals: return [] - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) if owner_id is None: @@ -358,8 +382,8 @@ class PgVectorStore(DocumentStore): async def has_collection(self, collection: str, principals: list[PrincipalId]) -> bool: if not principals: return False - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: owner_id = await self._readable_owner_for(cur, collection, principals) return owner_id is not None @@ -367,8 +391,8 @@ class PgVectorStore(DocumentStore): async def list_collections(self, principals: list[PrincipalId]) -> list[str]: if not principals: return [] - await self._ensure_schema() - async with await self._connect() as conn: + await self._ensure_ready() + async with self._pool.connection() as conn: async with conn.cursor() as cur: await cur.execute( """ @@ -383,5 +407,4 @@ class PgVectorStore(DocumentStore): return [r[0] for r in rows] async def close(self) -> None: - # Connections are opened and closed per call, so nothing persistent to release. - return None + await self._pool.close() diff --git a/engine/src/stirling/services/runtime.py b/engine/src/stirling/services/runtime.py index 685509cfe..06629347c 100644 --- a/engine/src/stirling/services/runtime.py +++ b/engine/src/stirling/services/runtime.py @@ -1,16 +1,22 @@ from __future__ import annotations +import asyncio import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import assert_never +from typing import Any, assert_never import httpx -from pydantic_ai.models import Model, infer_model +from pydantic_ai import RunContext +from pydantic_ai.messages import ModelMessage, ModelResponse +from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model from pydantic_ai.models.anthropic import AnthropicModel +from pydantic_ai.models.wrapper import WrapperModel from pydantic_ai.providers.anthropic import AnthropicProvider from pydantic_ai.settings import ModelSettings -from stirling.config import ENGINE_ROOT, AppSettings, RagBackend +from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend from stirling.documents import ( DocumentService, DocumentStore, @@ -40,6 +46,37 @@ def _build_anthropic_http_client() -> httpx.AsyncClient: return httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=0)) +class ConcurrencyLimitedModel(WrapperModel): + """Caps in-flight model API calls with a semaphore shared across the process.""" + + def __init__(self, wrapped: Model, semaphore: asyncio.Semaphore) -> None: + super().__init__(wrapped) + self._semaphore = semaphore + + async def request( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> ModelResponse: + async with self._semaphore: + return await super().request(messages, model_settings, model_request_parameters) + + @asynccontextmanager + async def request_stream( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + run_context: RunContext[Any] | None = None, + ) -> AsyncIterator[StreamedResponse]: + async with self._semaphore: + async with super().request_stream( + messages, model_settings, model_request_parameters, run_context + ) as response_stream: + yield response_stream + + @dataclass(frozen=True) class AppRuntime: settings: AppSettings @@ -74,17 +111,21 @@ def validate_structured_output_support(model: Model, model_name: str) -> None: def _build_document_store(settings: AppSettings) -> DocumentStore: """Build the configured document store backend.""" - if settings.rag_backend == RagBackend.SQLITE: - store_path = settings.rag_store_path + if settings.documents_backend == DocumentsBackend.SQLITE: + store_path = settings.documents_sqlite_path # Treat ":memory:" as a special in-process token; otherwise resolve against the engine root. if str(store_path) != ":memory:" and not store_path.is_absolute(): store_path = ENGINE_ROOT / store_path logger.info("Document store backend=sqlite, db_path=%s", store_path) return SqliteVecStore(db_path=store_path) - if settings.rag_backend == RagBackend.PGVECTOR: + if settings.documents_backend == DocumentsBackend.PGVECTOR: logger.info("Document store backend=pgvector, dsn=") - return PgVectorStore(dsn=settings.rag_pgvector_dsn) - assert_never(settings.rag_backend) + return PgVectorStore( + dsn=settings.documents_pgvector_dsn, + pool_min_size=settings.documents_pgvector_pool_min_size, + pool_max_size=settings.documents_pgvector_pool_max_size, + ) + assert_never(settings.documents_backend) def _build_documents(settings: AppSettings) -> DocumentService: @@ -105,10 +146,13 @@ def build_runtime(settings: AppSettings) -> AppRuntime: validate_structured_output_support(fast_model, settings.fast_model_name) validate_structured_output_support(smart_model, settings.smart_model_name) + # One semaphore across both tiers: the cap protects the provider account + # and process resources, which the tiers share. + model_semaphore = asyncio.Semaphore(settings.model_max_concurrency) return AppRuntime( settings=settings, - fast_model=fast_model, - smart_model=smart_model, + fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore), + smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore), documents=_build_documents(settings), ) diff --git a/engine/tests/conftest.py b/engine/tests/conftest.py index 1898b9129..d4c854313 100644 --- a/engine/tests/conftest.py +++ b/engine/tests/conftest.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from stirling.config import AppSettings, RagBackend, load_settings +from stirling.config import AppSettings, DocumentsBackend, load_settings from stirling.services import build_runtime from stirling.services.runtime import AppRuntime @@ -23,10 +23,13 @@ def build_app_settings() -> AppSettings: fast_model_name="test", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - rag_backend=RagBackend.SQLITE, + model_max_concurrency=32, + documents_backend=DocumentsBackend.SQLITE, rag_embedding_model="voyageai:voyage-4", - rag_store_path=Path(":memory:"), - rag_pgvector_dsn="", + documents_sqlite_path=Path(":memory:"), + documents_pgvector_dsn="", + documents_pgvector_pool_min_size=1, + documents_pgvector_pool_max_size=10, rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -41,6 +44,7 @@ def build_app_settings() -> AppSettings: contradiction_canonicaliser_batch_size=500, max_pages=200, max_characters=200_000, + require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/tests/pdf_comment/test_routes.py b/engine/tests/pdf_comment/test_routes.py index 6fce78f25..0056e10fe 100644 --- a/engine/tests/pdf_comment/test_routes.py +++ b/engine/tests/pdf_comment/test_routes.py @@ -16,7 +16,7 @@ from fastapi.testclient import TestClient from stirling.api import app from stirling.api.dependencies import get_pdf_comment_agent -from stirling.config import AppSettings, RagBackend, load_settings +from stirling.config import AppSettings, DocumentsBackend, load_settings from stirling.contracts.pdf_comments import ( PdfCommentInstruction, PdfCommentRequest, @@ -35,10 +35,13 @@ class StubSettingsProvider: fast_model_name="test", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - rag_backend=RagBackend.SQLITE, + model_max_concurrency=32, + documents_backend=DocumentsBackend.SQLITE, rag_embedding_model="test-embed", - rag_store_path=Path(":memory:"), - rag_pgvector_dsn="", + documents_sqlite_path=Path(":memory:"), + documents_pgvector_dsn="", + documents_pgvector_pool_min_size=1, + documents_pgvector_pool_max_size=10, rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -49,6 +52,7 @@ class StubSettingsProvider: chunked_reasoner_notes_char_budget=250_000, max_pages=100, max_characters=100_000, + require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/tests/test_model_concurrency.py b/engine/tests/test_model_concurrency.py new file mode 100644 index 000000000..ada8b5727 --- /dev/null +++ b/engine/tests/test_model_concurrency.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio + +import pytest +from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart +from pydantic_ai.models import Model, ModelRequestParameters +from pydantic_ai.settings import ModelSettings + +from stirling.services.runtime import ConcurrencyLimitedModel + + +class TrackingModel(Model): + """Records the high-water mark of concurrent in-flight requests.""" + + def __init__(self) -> None: + super().__init__() + self.active = 0 + self.max_active = 0 + + @property + def model_name(self) -> str: + return "tracking" + + @property + def system(self) -> str: + return "test" + + async def request( + self, + messages: list[ModelMessage], + model_settings: ModelSettings | None, + model_request_parameters: ModelRequestParameters, + ) -> ModelResponse: + self.active += 1 + self.max_active = max(self.max_active, self.active) + # Yield twice so every gathered task gets a chance to be in flight + # together before any of them completes. + await asyncio.sleep(0) + await asyncio.sleep(0) + self.active -= 1 + return ModelResponse(parts=[TextPart(content="ok")]) + + +@pytest.mark.anyio +async def test_shared_semaphore_caps_concurrency_across_models() -> None: + inner = TrackingModel() + semaphore = asyncio.Semaphore(2) + fast = ConcurrencyLimitedModel(inner, semaphore) + smart = ConcurrencyLimitedModel(inner, semaphore) + params = ModelRequestParameters() + + await asyncio.gather( + *(fast.request([], None, params) for _ in range(5)), + *(smart.request([], None, params) for _ in range(5)), + ) + + assert inner.max_active == 2 diff --git a/engine/tests/test_stirling_api.py b/engine/tests/test_stirling_api.py index 4eaecf90f..3a6497174 100644 --- a/engine/tests/test_stirling_api.py +++ b/engine/tests/test_stirling_api.py @@ -214,6 +214,26 @@ def test_pdf_edit_route() -> None: assert response.json()["outcome"] == "cannot_do" +def test_routes_require_user_id_when_enforced() -> None: + """With STIRLING_REQUIRE_USER_ID on, an identity-less request is rejected + at the boundary before any handler runs; supplying X-User-Id is accepted. + The other route tests in this module run with the flag off and cover the + identity-less self-hosted path.""" + app.dependency_overrides[load_settings] = lambda: build_app_settings().model_copy(update={"require_user_id": True}) + try: + anonymous = client.post("/api/v1/pdf/edit", json={"userMessage": "rotate this"}) + identified = client.post( + "/api/v1/pdf/edit", + json={"userMessage": "rotate this"}, + headers={"X-User-Id": "alice"}, + ) + finally: + app.dependency_overrides[load_settings] = build_app_settings + + assert anonymous.status_code == 401 + assert identified.status_code == 200 + + def test_pdf_questions_route() -> None: response = client.post( "/api/v1/pdf/questions", diff --git a/engine/tests/test_stirling_contracts.py b/engine/tests/test_stirling_contracts.py index ccc057adb..7270955c2 100644 --- a/engine/tests/test_stirling_contracts.py +++ b/engine/tests/test_stirling_contracts.py @@ -77,17 +77,20 @@ def test_pdf_question_answer_defaults_evidence_list() -> None: def test_app_settings_accepts_model_configuration() -> None: from pathlib import Path - from stirling.config import RagBackend + from stirling.config import DocumentsBackend settings = AppSettings( smart_model_name="claude-sonnet-4-5-20250929", fast_model_name="claude-haiku-4-5-20251001", smart_model_max_tokens=8192, fast_model_max_tokens=2048, - rag_backend=RagBackend.SQLITE, + model_max_concurrency=32, + documents_backend=DocumentsBackend.SQLITE, rag_embedding_model="voyageai:voyage-4", - rag_store_path=Path(":memory:"), - rag_pgvector_dsn="", + documents_sqlite_path=Path(":memory:"), + documents_pgvector_dsn="", + documents_pgvector_pool_min_size=1, + documents_pgvector_pool_max_size=10, rag_chunk_size=512, rag_chunk_overlap=64, rag_default_top_k=5, @@ -98,6 +101,7 @@ def test_app_settings_accepts_model_configuration() -> None: chunked_reasoner_notes_char_budget=250_000, max_pages=200, max_characters=200_000, + require_user_id=False, posthog_enabled=False, posthog_api_key="", posthog_host="https://eu.i.posthog.com", diff --git a/engine/uv.lock b/engine/uv.lock index c8f79e637..00a8345c1 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -607,7 +607,7 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "pgvector" }, { name = "posthog" }, - { name = "psycopg", extra = ["binary"] }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "pydantic-ai-slim", extra = ["voyageai"] }, @@ -633,7 +633,7 @@ requires-dist = [ { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "pgvector", specifier = ">=0.3.6" }, { name = "posthog", specifier = ">=3.0.0" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-ai", specifier = ">=1.67.0" }, { name = "pydantic-ai-slim", extras = ["voyageai"], specifier = ">=1.67.0" }, @@ -2165,6 +2165,9 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] +pool = [ + { name = "psycopg-pool" }, +] [[package]] name = "psycopg-binary" @@ -2195,6 +2198,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.4" From f16ca4795c3b1458ecb01a98acdac60f862f2c5b Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:50:27 +0100 Subject: [PATCH 37/80] fe(payg): remove em dashes from Plan page copy (#6610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Removes all em dash (`—`) characters from the **user-facing text** on the Plan page (PAYG section), replacing them with colons, commas, or restructured punctuation so the copy reads naturally. ## Changes - `frontend/editor/public/locales/en-GB/translation.toml` — all `payg.*` strings (this is what actually renders on the page) - `PaygFree.tsx` — `t()` default fallbacks + the `{" — "}` JSX benefit-list separators (now `{": "}`) - `Payg.tsx` — `t()` default fallback for the editor-plan body ## Notes - The en-dash range separator (`{{start}} – {{end}}`) in the billing-period string is intentionally **kept** — only em dashes were targeted. - JSDoc / code comments containing em dashes were **left unchanged**, since they aren't rendered text on the page. image --- .../public/locales/en-GB/translation.toml | 28 +++++++++---------- .../shared/config/configSections/Payg.tsx | 2 +- .../shared/config/configSections/PaygFree.tsx | 12 ++++---- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 86cbf5873..bcf7b4668 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -5160,7 +5160,7 @@ text = "Keep these guidelines in mind:" title = "Tips" [payg] -subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} – {{end}}." +subtitle = "Pay-as-you-go: you only pay for what you process. Billing period {{start}} – {{end}}." [payg.activity] subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." @@ -5196,7 +5196,7 @@ errorTitle = "Stripe error" startFailed = "Couldn't start checkout session" [payg.checkout.mock] -backend = "Backend is in mock mode — no real Stripe session was created." +backend = "Backend is in mock mode; no real Stripe session was created." continue = "Continue with mock subscription" noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured." title = "Stripe Embedded Checkout (mock mode)" @@ -5227,15 +5227,15 @@ benefit3Body = "call any Stirling endpoint programmatically" benefit3Title = "API access" button = "Turn on Processor →" reassurance = "No minimum · Set a $0 cap to test · Cancel anytime" -subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control." +subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control." title = "Turn on the Processor plan" [payg.free.editor] eyebrow = "Editor plan · Always free" [payg.free.explainer] -alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from." -alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted." +alwaysFreeBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from." +alwaysFreeForYouBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted." alwaysFreeForYouLabel = "Always free for you" alwaysFreeLabel = "Always free" countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor." @@ -5244,16 +5244,16 @@ sharedBody = "Automation pipelines, AI tools, and API calls share a single one-t sharedLabel = "Shared with the team" [payg.free.header] -subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." +subtitle = "Editor plan: manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." [payg.free.hero] capSuffix = "/ {{limit}} free PDFs" eyebrow = "Your free PDFs" metaCategories = "Automation · AI · API requests" -neverResets = "One-time — never resets" +neverResets = "One-time, never resets" [payg.free.member] -body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access share the team's one-time allowance of 500 free PDFs." +body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like. Automation, AI, and API access share the team's one-time allowance of 500 free PDFs." ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." title = "Want to process more than your 500 free PDFs?" @@ -5287,7 +5287,7 @@ leader = "Team owner" member = "Member" [payg.signupRequired] -body = "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing." +body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing." cancel = "Not now" cta = "Sign up free" subtext = "Creating an account is free and takes a few seconds. No credit card required." @@ -5326,11 +5326,11 @@ finish = "Finish" customPlaceholder = "Or enter your own amount ($0 keeps it free)" help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." noCapHint = "You can still cancel anytime from the customer portal." -noCapLabel = "No cap — I'll manage spend manually" +noCapLabel = "No cap, I'll manage spend manually" perMonthSuffix = "/ month" presetsAria = "Monthly cap preset" title = "Set your monthly spend ceiling" -usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency." +usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." [payg.upgrade.checkout] capLabel = "Monthly ceiling:" @@ -5343,7 +5343,7 @@ title = "Add your payment method" [payg.upgrade.footer] capHint = "You can change your cap any time later." -checkoutHint = "Card details handled by Stripe — never touched by Stirling." +checkoutHint = "Card details handled by Stripe, never touched by Stirling." confirmHint = "Your wallet will refresh automatically in a moment." [payg.upgrade.help] @@ -5353,11 +5353,11 @@ apiBody = "programmatic access to any Stirling endpoint" apiTitle = "API calls" automationBody = "chained tools or scheduled runs that don't need clicks" automationTitle = "Automation pipelines" -footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click." +footnote = "Manual tools (viewing, editing, merging, splitting, watermarking, compressing, manual OCR) are always free, even past 500. The distinction is the type of work, not where you click." title = "What we count toward billing" [payg.upgrade.promise] -body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge." +body = "You only pay for automation pipelines, AI tools, and API calls, the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge." highlight = "Manual tools stay free, always." [payg.upgrade.steps] diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx index b431c1386..164ae9caa 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx @@ -708,7 +708,7 @@ const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) =>

{t( "payg.header.freeBody", - "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.", + "View, edit, merge, split, sign, watermark, compress, convert and manual OCR, as much as you want, no matter where you trigger it.", )}

diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx index 2c4a58cd1..27d77be48 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -123,7 +123,7 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {

{t( "payg.free.header.freeBody", - "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.", + "View, edit, merge, split, sign, watermark, compress, convert and manual OCR, as much as you want, no matter where you trigger it.", )}

@@ -174,7 +174,7 @@ function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} - {t("payg.free.hero.neverResets", "One-time — never resets")} + {t("payg.free.hero.neverResets", "One-time, never resets")}
); @@ -207,7 +207,7 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {

{t( "payg.free.cta.subtitle", - "Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.", + "Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling, so you stay in control.", { limit: snap.billableLimit.toLocaleString() }, )}

@@ -219,7 +219,7 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) { {t("payg.free.cta.benefit1Title", "Automation pipelines")} - {" — "} + {": "} {t( "payg.free.cta.benefit1Body", "chain tools, schedule runs, batch process", @@ -230,7 +230,7 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) { {t("payg.free.cta.benefit2Title", "AI tools")} - {" — "} + {": "} {t( "payg.free.cta.benefit2Body", "summarise, classify, redact, AI-OCR", @@ -241,7 +241,7 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) { {t("payg.free.cta.benefit3Title", "API access")} - {" — "} + {": "} {t( "payg.free.cta.benefit3Body", "call any Stirling endpoint programmatically", From 5bc7ae626d1a2e73387ee094b61db5ab80c241f8 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:26:58 +0100 Subject: [PATCH 38/80] fix(payg): cancelled subscription left team gated as subscribed (#6611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A team that **cancelled** its PAYG subscription kept full subscribed access: - **UI didn't reflect cancellation** — the Plan tab still rendered the subscribed view, never the free/upgrade view. - **Automation wasn't stopped** — automation / AI / API kept running without ever falling back to the free-grant gate. ## Root cause `TeamBillingService.compute` decided `subscribed` as: ```java boolean subscribed = subscriptionId != null || extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent(); ``` On cancellation, the `customer.subscription.deleted` webhook calls `payg_unlink_subscription`, which nulls `payg_subscription_id` but **deliberately keeps `stripe_customer_id`** (so a future re-subscribe can reuse the Stripe customer). `payg_link_subscription` is the **only** writer of `payg_team_extensions.stripe_customer_id`, and it writes it in the *same* `UPDATE` as `payg_subscription_id` (on `customer.subscription.created`). So the customer id is never set before the subscription id — the "pre-webhook stand-in" the old comment claimed **cannot happen**. The fallback only ever pinned a team that *ever* subscribed to `subscribed` forever, because the Stripe customer outlives the subscription. Both symptoms are this one flag: - `PaygWalletController` status → `SUBSCRIBED` vs `FREE` - `EntitlementService` gate branch → monthly-cap vs free-grant ## Fix Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled team now correctly drops to free (UI shows free; billable ops gate on the one-time grant). This aligns the wallet/entitlement read with the **meter path** (`JobChargeService.close`), which already gated on `payg_subscription_id`. Handles both Stripe cancel modes: "cancel at period end" keeps the sub `active` (id stays set) until `.deleted` fires at period end → access through the paid period; immediate cancel fires `.deleted` now → flips to free now. **No data migration / backfill** — already-cancelled teams have `payg_subscription_id = NULL`, so they flip to free as soon as this ships (within the 30s billing-cache TTL). ## Tests Adds `TeamBillingServiceTest` — the `subscribed` computation previously had **no** unit coverage (which is how this shipped). Covers: subscribed iff subscription id present; **cancelled team (customer id remains, subscription id null) ≠ subscribed** + free grant survives; no-subscription/no-customer; no extension row. `:saas:test` + `:saas:spotlessCheck` green; coverage gates met. ## Not included (optional hardening, can fast-follow) - Cross-check the synced `stripe.subscriptions.status` to guard a *missed* `.deleted` webhook leaving `payg_subscription_id` stale. - Push cache-invalidation from the webhook (currently ≤30s TTL staleness). --- .../saas/payg/billing/TeamBillingContext.java | 4 +- .../saas/payg/billing/TeamBillingService.java | 21 +-- .../payg/billing/TeamBillingServiceTest.java | 121 ++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java index 52cb15bb2..55988fbe8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java @@ -16,8 +16,8 @@ import java.time.LocalDateTime; * + cap only. * * - * @param subscribed team has an active PAYG subscription (or a Stripe customer awaiting its - * subscription-created webhook) + * @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set. + * Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false. * @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free * @param periodStart inclusive start of the monthly billing window — the Stripe subscription's * current period when subscribed, calendar month otherwise diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java index 9a697392d..260efbd9a 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -111,14 +111,19 @@ public class TeamBillingService { Optional walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId); String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null); - // payg_subscription_id is the designed switch; stripe_customer_id presence is the - // pre-webhook stand-in kept so a team whose checkout completed but whose - // subscription-created webhook hasn't landed yet still renders as subscribed. - boolean subscribed = - subscriptionId != null - || extOpt.map(PaygTeamExtensions::getStripeCustomerId) - .filter(s -> !s.isBlank()) - .isPresent(); + // payg_subscription_id is the single subscription switch. payg_link_subscription sets it + // (alongside stripe_customer_id, in the same write) on customer.subscription.created; + // payg_unlink_subscription nulls it on customer.subscription.deleted while deliberately + // keeping stripe_customer_id so a future re-subscribe can reuse the Stripe customer. So a + // cancelled team has a null subscription id and must read as free again. + // + // We deliberately do NOT fall back to stripe_customer_id presence. payg_link_subscription + // is the only writer of that column and it writes it together with the subscription id, so + // it can never be set "before the webhook lands" — there is no gap for it to bridge. A + // customer-id fallback would instead keep every team that ever subscribed pinned to + // subscribed forever (the customer outlives the subscription), which is the cancelled-team + // bug this guards against. + boolean subscribed = subscriptionId != null; long freeGrant = resolveGrant(teamId); long freeRemaining = diff --git a/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java new file mode 100644 index 000000000..66e2eb074 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceTest.java @@ -0,0 +1,121 @@ +package stirling.software.saas.payg.billing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao; + +/** + * Unit tests for {@link TeamBillingService#forTeam(Long)} — specifically the {@code subscribed} + * determination, which is the single switch both the wallet UI ({@code status}) and the entitlement + * gate read. + * + *

Regression focus: a team is subscribed iff {@code payg_subscription_id} is set. {@code + * payg_unlink_subscription} nulls that column on {@code customer.subscription.deleted} but + * deliberately keeps {@code stripe_customer_id} (for a future re-subscribe), so a cancelled team + * must read as free again. An earlier fallback treated customer-id presence as subscribed, + * which kept every team that ever subscribed pinned to subscribed forever — the cancelled-team bug + * these tests lock down. + */ +class TeamBillingServiceTest { + + private static final long TEAM_ID = 100L; + + private PaygTeamExtensionsRepository extensionsRepository; + private WalletPolicyRepository walletPolicyRepository; + private PricingPolicyService pricingPolicyService; + private StripeSubscriptionDao subscriptionDao; + private TeamBillingService service; + + @BeforeEach + void setUp() { + extensionsRepository = Mockito.mock(PaygTeamExtensionsRepository.class); + walletPolicyRepository = Mockito.mock(WalletPolicyRepository.class); + pricingPolicyService = Mockito.mock(PricingPolicyService.class); + subscriptionDao = Mockito.mock(StripeSubscriptionDao.class); + service = + new TeamBillingService( + extensionsRepository, + walletPolicyRepository, + pricingPolicyService, + subscriptionDao); + + // Default grant so the free-tier fields are populated; individual tests don't depend on it + // beyond the cancelled-team case below, which asserts the grant survives. + PricingPolicy policy = Mockito.mock(PricingPolicy.class); + when(policy.getFreeTierUnits()).thenReturn(500L); + when(pricingPolicyService.getEffectivePolicy(TEAM_ID)).thenReturn(policy); + } + + private PaygTeamExtensions ext(String subscriptionId, String customerId, long freeRemaining) { + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(TEAM_ID); + ext.setPaygSubscriptionId(subscriptionId); + ext.setStripeCustomerId(customerId); + ext.setFreeUnitsRemaining(freeRemaining); + return ext; + } + + @Test + void subscribed_whenSubscriptionIdPresent() { + when(extensionsRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(ext("sub_123", "cus_123", 0L))); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isTrue(); + assertThat(ctx.subscriptionId()).isEqualTo("sub_123"); + } + + /** + * The cancelled-subscription regression: after {@code payg_unlink_subscription} the + * subscription id is null but the Stripe customer id remains. The team must read as NOT + * subscribed (drops to the free-grant gate), and must still surface its remaining free grant. + */ + @Test + void notSubscribed_afterCancellation_whenOnlyCustomerIdRemains() { + when(extensionsRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(ext(null, "cus_123", 120L))); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isFalse(); + assertThat(ctx.subscriptionId()).isNull(); + // The free grant survives cancellation and is what now gates the team. + assertThat(ctx.freeGrantUnits()).isEqualTo(500L); + assertThat(ctx.freeRemainingUnits()).isEqualTo(120L); + // Not subscribed → no monthly paid-doc cap. + assertThat(ctx.monthlyCapDocUnits()).isNull(); + } + + @Test + void notSubscribed_whenNoSubscriptionAndNoCustomer() { + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, null, 500L))); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isFalse(); + assertThat(ctx.subscriptionId()).isNull(); + } + + @Test + void notSubscribed_whenNoExtensionRow() { + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isFalse(); + assertThat(ctx.subscriptionId()).isNull(); + } +} From 34ead6019440df46f42c56b482ce6884d2bb5e98 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 11 Jun 2026 17:28:05 +0100 Subject: [PATCH 39/80] Kill off agents pane now that we have FAB (#6613) # Description of Changes Kill off agents pane now that we have the FAB. Also fixes a bug with the FAB where it would sometimes fail to render the chat, and fixes a duplicated entry in the Vite config which was throwing a warning --- .../core/components/agents/AgentsPanel.tsx | 43 -- .../src/core/components/chat/ChatContext.tsx | 3 - .../components/tools/FullscreenToolPanel.tsx | 17 +- .../tools/FullscreenToolSurface.tsx | 6 - .../core/components/tools/RightSidebar.tsx | 129 +---- .../src/core/components/tools/ToolPanel.css | 36 -- .../components/agents/AgentsPanel.css | 483 ------------------ .../components/agents/AgentsPanel.tsx | 275 ---------- .../components/chat/ChatContext.tsx | 19 - .../proprietary/components/chat/ChatFAB.tsx | 17 +- .../proprietary/components/chat/ChatPanel.css | 31 +- frontend/editor/vite.config.ts | 5 - 12 files changed, 58 insertions(+), 1006 deletions(-) delete mode 100644 frontend/editor/src/core/components/agents/AgentsPanel.tsx delete mode 100644 frontend/editor/src/proprietary/components/agents/AgentsPanel.css delete mode 100644 frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx diff --git a/frontend/editor/src/core/components/agents/AgentsPanel.tsx b/frontend/editor/src/core/components/agents/AgentsPanel.tsx deleted file mode 100644 index d5aeee493..000000000 --- a/frontend/editor/src/core/components/agents/AgentsPanel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Core stubs for the right-rail Agents UI. - * - * The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx} - * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary - * build is active. Core builds render nothing, so the right rail collapses to the - * tool list unchanged. - */ - -/** Whether the right rail should reserve space for agents UI. False in core. */ -export function useAgentsEnabled(): boolean { - return false; -} - -/** - * Whether the agent chat panel is currently open. Core builds have no chat, - * so this always returns false. Proprietary builds bridge to the ChatContext. - */ -export function useAgentChatOpen(): boolean { - return false; -} - -/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */ -export function AgentsSection() { - return null; -} - -/** - * Icon-only agent button rendered in the collapsed (minimised) right rail. - * Returns null in core; proprietary renders the Stirling agent shortcut. - */ -export function AgentsCollapsedButton(_props: { onExpand: () => void }) { - return null; -} - -/** - * Agents card rendered inside the fullscreen tool picker. Matches the visual - * language of the fullscreen category cards (gradient border, title, items). - * Returns null in core; proprietary renders the Stirling agent. - */ -export function AgentsFullscreenSection() { - return null; -} diff --git a/frontend/editor/src/core/components/chat/ChatContext.tsx b/frontend/editor/src/core/components/chat/ChatContext.tsx index 621fafd6e..e944bd5c2 100644 --- a/frontend/editor/src/core/components/chat/ChatContext.tsx +++ b/frontend/editor/src/core/components/chat/ChatContext.tsx @@ -7,12 +7,9 @@ export function useChat() { return { messages: [] as never[], - isOpen: false, isLoading: false, progress: null, progressLog: [] as never[], - toggleOpen: () => {}, - setOpen: (_open: boolean) => {}, sendMessage: async (_content: string) => {}, clearChat: () => {}, }; diff --git a/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx b/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx index 68a0bb3a0..94eb5de45 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolPanel.tsx @@ -3,11 +3,6 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useIsMobile } from "@app/hooks/useIsMobile"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; -import { - AgentsFullscreenSection, - useAgentChatOpen, - useAgentsEnabled, -} from "@app/components/agents/AgentsPanel"; import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface"; import { ToolId } from "@app/types/toolId"; import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; @@ -16,13 +11,11 @@ import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; export function useIsFullscreenExpanded(): boolean { const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow(); const isMobile = useIsMobile(); - const agentChatOpen = useAgentChatOpen(); return ( toolPanelMode === "fullscreen" && leftPanelView === "toolPicker" && !isMobile && - !readerMode && - !agentChatOpen + !readerMode ); } @@ -50,8 +43,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) { handleToolSelect, } = useToolWorkflow(); const isMobile = useIsMobile(); - const agentsEnabled = useAgentsEnabled(); - const agentChatOpen = useAgentChatOpen(); const { setAllButtonsDisabled } = useWorkbenchBar(); const { preferences, updatePreference } = usePreferences(); @@ -59,8 +50,7 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) { toolPanelMode === "fullscreen" && leftPanelView === "toolPicker" && !isMobile && - !readerMode && - !agentChatOpen; + !readerMode; useEffect(() => { setAllButtonsDisabled(fullscreenExpanded); @@ -94,9 +84,6 @@ export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) { } onExitFullscreenMode={() => setToolPanelMode("sidebar")} geometry={geometry} - agentsSlot={ - agentsEnabled && !searchQuery ? : null - } /> ); } diff --git a/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx index e1ad45d46..608878522 100644 --- a/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/editor/src/core/components/tools/FullscreenToolSurface.tsx @@ -25,8 +25,6 @@ interface FullscreenToolSurfaceProps { onToggleDescriptions: () => void; onExitFullscreenMode: () => void; geometry: ToolPanelGeometry | null; - /** Optional agents block rendered above the tool list. */ - agentsSlot?: React.ReactNode; } const FullscreenToolSurface = ({ @@ -41,7 +39,6 @@ const FullscreenToolSurface = ({ onToggleDescriptions, onExitFullscreenMode: _onExitFullscreenMode, geometry, - agentsSlot, }: FullscreenToolSurfaceProps) => { const { t } = useTranslation(); const surfaceRef = useRef(null); @@ -92,9 +89,6 @@ const FullscreenToolSurface = ({ className="tool-panel__fullscreen-scroll" offsetScrollbars > - {agentsSlot && ( -

{agentsSlot}
- )} ( - null, - ); - - const handleChatClose = useCallback(() => { - withViewTransition(() => setChatOpen(false)); - setChatWidthPx(DEFAULT_CHAT_WIDTH_PX); - }, [setChatOpen]); - - const handleResizeChatPointerDown = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - chatDragState.current = { startX: e.clientX, startWidth: chatWidthPx }; - setIsChatDragging(true); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - - const onMove = (ev: PointerEvent) => { - if (!chatDragState.current) return; - const delta = chatDragState.current.startX - ev.clientX; - setChatWidthPx( - Math.max( - MIN_CHAT_WIDTH_PX, - Math.min( - MAX_CHAT_WIDTH_PX, - chatDragState.current.startWidth + delta, - ), - ), - ); - }; - - const cleanup = () => { - chatDragState.current = null; - setIsChatDragging(false); - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", cleanup); - window.removeEventListener("pointercancel", cleanup); - }; - - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", cleanup); - window.addEventListener("pointercancel", cleanup); - }, - [chatWidthPx], - ); - const handleShowAllTools = () => { withViewTransition(() => setAllToolsView(true)); }; @@ -186,12 +123,11 @@ export default function RightSidebar() { policiesEnabled && !allToolsView && leftPanelView === "toolPicker"; // When Policies are shown, the search moves OUT of the header to sit between // the Policies and Tools sections (separating them); otherwise it stays in the - // header. Show the header search when there's a close button, or when agents - // are off in the default tool-picker view (inline filtering). + // header. Show the header search when there's a close button, or in the + // default tool-picker view. const showInlineSearch = showPolicies && !showCloseButton; const showHeaderSearch = - !showInlineSearch && - (showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker")); + !showInlineSearch && (showCloseButton || leftPanelView === "toolPicker"); const handleHeaderBack = () => { if (inToolView) { @@ -224,13 +160,6 @@ export default function RightSidebar() { ? (toolRegistry[selectedToolKey as ToolId] ?? null) : null; - // Agents header + section is hidden when: - // - the AI engine is off, or - // - the user is in the all-tools view, or - // - a specific tool is being rendered (leftPanelView ≠ "toolPicker"). - const showAgents = - agentsEnabled && !allToolsView && leftPanelView === "toolPicker"; - // The detail takeover replaces the tool list ONLY in the same default view — // never over an open tool or the all-tools view (which must keep priority). // A lingering selection is harmless: it stays hidden behind a tool and the @@ -243,7 +172,6 @@ export default function RightSidebar() { const computedWidth = () => { if (isMobile) return "100%"; - if (isChatOpen) return `${chatWidthPx}px`; if (!isPanelVisible) return "3.5rem"; return expandedWidth; }; @@ -279,41 +207,17 @@ export default function RightSidebar() { ref={toolPanelRef} data-sidebar="tool-panel" data-tour={fullscreenExpanded ? undefined : "tool-panel"} - className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : isChatOpen ? "" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ + className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ isRainbowMode ? rainbowStyles.rainbowPaper : "" } ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} style={{ width: computedWidth(), padding: "0", - ...(isChatDragging ? { transition: "none" } : {}), }} > {/* Headless: enforces enabled policies on every uploaded file. */} {policiesEnabled && } - {!fullscreenExpanded && isChatOpen && ( -
-
- -
- )} - - {!fullscreenExpanded && !isChatOpen && !isPanelVisible && !isMobile && ( + {!fullscreenExpanded && !isPanelVisible && !isMobile && (
-
@@ -358,7 +261,7 @@ export default function RightSidebar() {
)} - {!fullscreenExpanded && !isChatOpen && isPanelVisible && ( + {!fullscreenExpanded && isPanelVisible && (
- ) : ( - showAgents && ( - - {t("agents.section_title", "Agents")} - - ) - )} + ) : null} {showCloseButton ? ( )} - {showAgents && } - {showPolicies && ( )} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 103a24ad9..9995781e3 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -385,15 +385,6 @@ } } -.tool-panel__section-label { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - line-height: 1; -} - .tool-panel__mode-toggle { transition: transform 0.2s ease; } @@ -855,33 +846,6 @@ gap: 0.75rem; } -/* Agents card lives in its own column above the tool grid. It uses the same - .tool-panel__fullscreen-group base styles but with a colourful gradient - border so it stands out as a distinct surface (and doesn't blend into the - neighbouring category cards). */ -.tool-panel__fullscreen-agents { - padding: 1.5rem 1.75rem 0; -} - -.tool-panel__fullscreen-group--agents { - position: relative; - background: - linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group)) - padding-box, - linear-gradient( - 135deg, - var(--mantine-color-blue-6) 0%, - var(--mantine-color-violet-6) 45%, - var(--mantine-color-pink-6) 100% - ) - border-box; - border: 1.5px solid transparent; -} - -.tool-panel__fullscreen-section-icon--agents { - color: var(--mantine-color-blue-6); -} - @keyframes tool-panel-fullscreen-slide-in { from { transform: translateX(6%) scaleX(0.85); diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.css b/frontend/editor/src/proprietary/components/agents/AgentsPanel.css deleted file mode 100644 index 864c9527d..000000000 --- a/frontend/editor/src/proprietary/components/agents/AgentsPanel.css +++ /dev/null @@ -1,483 +0,0 @@ -/* ─── Chat takeover ─────────────────────────────────────────────────────── */ - -.agents-takeover { - position: fixed; - right: 0; - top: 0; - bottom: 0; - display: flex; - flex-direction: column; - background: var(--bg-toolbar); - border-left: 1px solid var(--border-subtle); - view-transition-name: agents-rail; -} - -.agents-takeover__resize-handle { - position: absolute; - left: -3px; - top: 0; - bottom: 0; - width: 7px; - cursor: col-resize; - z-index: 10; - touch-action: none; -} - -.agents-takeover__resize-handle::after { - content: ""; - position: absolute; - left: 3px; - top: 0; - bottom: 0; - width: 1px; - background: transparent; - transition: - background 150ms ease, - width 150ms ease, - left 150ms ease; -} - -.agents-takeover__resize-handle:hover::after { - left: 2px; - width: 3px; - background: var(--mantine-color-blue-4); - opacity: 0.6; -} - -/* ─── Sidebar agents section ─────────────────────────────────────────────── */ - -.agents-section { - padding: 0.5rem 0.75rem 0.875rem; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-toolbar); - flex-shrink: 0; - view-transition-name: agents-rail; -} - -/* Hero button — real Stirling agent */ -.agent-button { - display: block; - width: 100%; - padding: 0.75rem; - border: 1px solid - color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle)); - border-radius: 0.625rem; - background: linear-gradient( - 135deg, - color-mix( - in srgb, - var(--mantine-color-blue-6) 6%, - var(--mantine-color-body) - ) - 0%, - var(--mantine-color-body) 100% - ); - transition: - background 150ms ease-out, - border-color 150ms ease-out; -} - -.agent-button:hover { - border-color: color-mix( - in srgb, - var(--mantine-color-blue-5) 65%, - var(--border-subtle) - ); - background: linear-gradient( - 135deg, - color-mix( - in srgb, - var(--mantine-color-blue-6) 12%, - var(--mantine-color-default-hover) - ) - 0%, - var(--mantine-color-default-hover) 100% - ); -} - -.agent-button__logo { - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--mantine-color-blue-filled); - flex-shrink: 0; - position: relative; -} - -/* Coming-soon agent rows */ -.agents-sidebar-list { - display: flex; - flex-direction: column; - gap: 0.375rem; - margin-top: 0.375rem; -} - -.agent-button--coming-soon { - display: block; - width: 100%; - padding: 0.75rem; - border: 1px solid var(--border-subtle); - border-radius: 0.625rem; - background: var(--mantine-color-body); - cursor: not-allowed !important; - opacity: 0.65; - transition: - opacity 180ms ease-out, - transform 180ms ease-out, - border-color 120ms ease-out; -} - -@starting-style { - .agent-button--coming-soon { - opacity: 0; - transform: translateY(5px); - } -} - -.agent-button--coming-soon:hover { - opacity: 0.85; - border-color: color-mix( - in srgb, - var(--mantine-color-gray-4) 60%, - var(--border-subtle) - ); - background: var(--mantine-color-default-hover); -} - -.agent-button__icon-plain { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - color: var(--mantine-color-dimmed); - flex-shrink: 0; -} - -.agents-view-all { - all: unset; - display: block; - width: 100%; - margin-top: 0.375rem; - padding: 0.25rem 0.625rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--mantine-color-dimmed); - text-align: center; - cursor: pointer; - opacity: 0.7; - border-radius: 0.375rem; - transition: opacity 120ms ease-out; - box-sizing: border-box; -} - -.agents-view-all:hover { - opacity: 1; -} - -/* Dark mode — sidebar */ -[data-mantine-color-scheme="dark"] .agent-button--coming-soon { - background: transparent; - border-color: color-mix( - in srgb, - var(--mantine-color-dark-4) 60%, - transparent - ); -} - -[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover { - border-color: var(--mantine-color-dark-3); - background: var(--mantine-color-dark-6); -} - -[data-mantine-color-scheme="dark"] .agent-button { - border-color: color-mix( - in srgb, - var(--mantine-color-blue-7) 55%, - var(--border-subtle) - ); - background: linear-gradient( - 135deg, - color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%, - transparent 100% - ); -} - -[data-mantine-color-scheme="dark"] .agent-button:hover { - border-color: color-mix( - in srgb, - var(--mantine-color-blue-5) 60%, - var(--border-subtle) - ); - background: linear-gradient( - 135deg, - color-mix( - in srgb, - var(--mantine-color-blue-8) 45%, - rgba(255, 255, 255, 0.04) - ) - 0%, - rgba(255, 255, 255, 0.04) 100% - ); -} - -[data-mantine-color-scheme="dark"] .agent-button__logo { - color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); -} - -/* ─── Collapsed-rail agent button ────────────────────────────────────────── */ - -.agents-collapsed-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - padding: 0; - border: none; - border-radius: 0.5rem; - background: transparent; - color: var(--mantine-color-blue-filled); - cursor: pointer; - transition: background 120ms ease-out; - position: relative; -} - -.agents-collapsed-btn:hover { - background: var(--mantine-color-default-hover); -} - -/* ─── Running / in-progress status dot ──────────────────────────────────── */ - -.agent-status-dot { - position: absolute; - bottom: 0; - right: 0; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--mantine-color-blue-5); - border: 1.5px solid var(--mantine-color-body); - animation: agent-dot-pulse 2.4s ease-in-out infinite; - pointer-events: none; -} - -@keyframes agent-dot-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.45; - } -} - -/* Blue outline on hero Stirling button when agent is running */ -.agent-button.agent-button--running { - border-color: color-mix( - in srgb, - var(--mantine-color-blue-5) 60%, - var(--border-subtle) - ); -} - -[data-mantine-color-scheme="dark"] .agent-button.agent-button--running { - border-color: color-mix( - in srgb, - var(--mantine-color-blue-4) 55%, - var(--border-subtle) - ); -} - -@media (prefers-reduced-motion: reduce) { - .agent-status-dot { - animation: none; - } -} - -/* ─── Fullscreen hero card ───────────────────────────────────────────────── */ - -/* - * .tool-panel__fullscreen-group--agents gives the gradient border via the - * padding-box / border-box trick. We only override the padding-box with a - * very light tint so the card doesn't read as a colourful surface. - */ -.agents-hero { - padding: 1.25rem 1.5rem 1.5rem; - border-radius: 1rem; - background: - linear-gradient( - 125deg, - color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%, - color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%, - color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100% - ) - padding-box, - linear-gradient( - 135deg, - var(--mantine-color-blue-6) 0%, - var(--mantine-color-violet-6) 45%, - var(--mantine-color-pink-6) 100% - ) - border-box; -} - -/* Body: left CTA + right grid */ -.agents-hero__body { - display: flex; - gap: 2rem; - align-items: flex-start; -} - -/* ── Left CTA — plain content, only the button inside is interactive ── */ -.agents-hero__cta { - display: flex; - flex-direction: column; - gap: 0; - flex: 0 0 18rem; - box-sizing: border-box; - padding: 0.25rem 0; -} - -.agents-hero__cta-logo { - color: var(--mantine-color-blue-filled); - margin-bottom: 0.625rem; -} - -.agents-hero__cta-headline { - font-size: 1.375rem; -} - -.agents-hero__cta-btn { - all: unset; - margin-top: 1.25rem; - display: inline-flex; - align-items: center; - padding: 0.4375rem 1rem; - border-radius: 0.5rem; - background: var(--mantine-color-blue-6); - color: white; - font-size: 0.8125rem; - font-weight: 600; - line-height: 1; - width: fit-content; - cursor: pointer; - transition: background 0.15s ease; -} - -.agents-hero__cta-btn:hover { - background: var(--mantine-color-blue-7); -} - -.agents-hero__cta-btn:focus-visible { - outline: 2px solid var(--mantine-color-blue-5); - outline-offset: 3px; - border-radius: 0.5rem; -} - -/* ── Right 2×3 grid — sizes to content, pushed to the right ── */ -.agents-hero__grid { - flex: 0 0 auto; - margin-left: auto; - display: grid; - /* auto columns: each column = widest item in that column */ - grid-template-columns: repeat(2, auto); - gap: 0.625rem; - align-content: start; - align-self: start; -} - -/* Solid white cards — no opacity so the white is opaque */ -.agents-hero__grid-item { - all: unset; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 0.875rem; - border: 1px solid rgba(0, 0, 0, 0.07); - border-radius: 0.75rem; - background: var(--mantine-color-body); - cursor: not-allowed; - box-sizing: border-box; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.agents-hero__grid-icon { - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--mantine-color-blue-5); -} - -.agents-hero__grid-body { - min-width: 0; - flex: 1; -} - -/* ── Dark mode — fullscreen ── */ -[data-mantine-color-scheme="dark"] .agents-hero { - background: - linear-gradient( - 125deg, - color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%, - color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%, - color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100% - ) - padding-box, - linear-gradient( - 135deg, - var(--mantine-color-blue-6) 0%, - var(--mantine-color-violet-6) 45%, - var(--mantine-color-pink-6) 100% - ) - border-box; -} - -[data-mantine-color-scheme="dark"] .agents-hero__cta-logo { - color: var(--mantine-color-blue-3); -} - -[data-mantine-color-scheme="dark"] .agents-hero__grid-item { - background: var(--fullscreen-bg-item); - border-color: var(--fullscreen-border-subtle-70); - box-shadow: none; -} - -[data-mantine-color-scheme="dark"] .agents-hero__grid-icon { - color: var(--mantine-color-blue-3); -} - -/* ─── agents-rail view transition — slide up ─────────────────────────────── */ - -::view-transition-old(agents-rail) { - animation: vt-agents-out 150ms ease-out forwards; -} - -::view-transition-new(agents-rail) { - animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards; -} - -@keyframes vt-agents-out { - to { - opacity: 0; - } -} - -@keyframes vt-agents-in { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (prefers-reduced-motion: reduce) { - ::view-transition-old(agents-rail), - ::view-transition-new(agents-rail) { - animation-duration: 1ms !important; - } -} diff --git a/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx b/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx deleted file mode 100644 index 8799596d9..000000000 --- a/frontend/editor/src/proprietary/components/agents/AgentsPanel.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useState } from "react"; -import type { ElementType } from "react"; -import { Box, Group, Text, UnstyledButton } from "@mantine/core"; -import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded"; -import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded"; -import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded"; -import GavelRoundedIcon from "@mui/icons-material/GavelRounded"; -import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded"; -import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import { useTranslation } from "react-i18next"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; -import { useChat } from "@app/components/chat/ChatContext"; -import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; -import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline"; -import { withViewTransition } from "@app/utils/viewTransition"; -import "@app/components/agents/AgentsPanel.css"; - -interface ComingSoonAgent { - id: string; - nameKey: string; - descriptionKey: string; - Icon: ElementType; -} - -const COMING_SOON_AGENTS: ComingSoonAgent[] = [ - { - id: "data-extraction", - nameKey: "agents.data_extraction_name", - descriptionKey: "agents.data_extraction_description", - Icon: TableChartRoundedIcon, - }, - { - id: "doc-summary", - nameKey: "agents.doc_summary_name", - descriptionKey: "agents.doc_summary_description", - Icon: SummarizeRoundedIcon, - }, - { - id: "auto-redaction", - nameKey: "agents.auto_redaction_name", - descriptionKey: "agents.auto_redaction_description", - Icon: VisibilityOffRoundedIcon, - }, - { - id: "compliance", - nameKey: "agents.compliance_name", - descriptionKey: "agents.compliance_description", - Icon: GavelRoundedIcon, - }, - { - id: "form-filler", - nameKey: "agents.form_filler_name", - descriptionKey: "agents.form_filler_description", - Icon: AssignmentRoundedIcon, - }, - { - id: "pdf-to-markdown", - nameKey: "agents.pdf_to_markdown_name", - descriptionKey: "agents.pdf_to_markdown_description", - Icon: CodeRoundedIcon, - }, -]; - -export function useAgentsEnabled(): boolean { - const { config } = useAppConfig(); - return Boolean(config?.aiEngineEnabled); -} - -export function useAgentChatOpen(): boolean { - const { isOpen } = useChat(); - return isOpen; -} - -const PREVIEW_COUNT = 3; - -/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */ -export function AgentsSection() { - const { t } = useTranslation(); - const { isOpen, setOpen, isLoading } = useChat(); - const enabled = useAgentsEnabled(); - const [showAll, setShowAll] = useState(false); - - if (!enabled || isOpen) return null; - - const comingSoonLabel = t("agents.coming_soon", "Coming soon"); - const visibleAgents = showAll - ? COMING_SOON_AGENTS - : COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT); - - return ( - - {/* Main Stirling agent — real and clickable */} - withViewTransition(() => setOpen(true))} - aria-label={t("agents.stirling_name", "Stirling")} - > - - - - {isLoading && } - - - - {t("agents.stirling_name", "Stirling")} - - - {isLoading - ? t("agents.stirling_running", "Running...") - : t( - "agents.stirling_description", - "Your general-purpose PDF assistant", - )} - - - - - - {/* Coming-soon agents */} -
- {visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => ( - - - - - - - - - {t(nameKey)} - - - {t(descriptionKey)} - - - - - - ))} -
- - {!showAll ? ( - - ) : ( - - )} -
- ); -} - -/** Icon-only agent button in the collapsed (minimised) right rail. */ -export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) { - const { t } = useTranslation(); - const { setOpen, isLoading } = useChat(); - const enabled = useAgentsEnabled(); - - if (!enabled) return null; - - const label = t("agents.stirling_tooltip", "Stirling agent"); - - return ( - - { - onExpand(); - setOpen(true); - }} - aria-label={label} - className="agents-collapsed-btn" - > - - {isLoading && } - - - ); -} - -/** - * Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on - * the right. Matches the gradient border of the other fullscreen category cards. - */ -export function AgentsFullscreenSection() { - const { t } = useTranslation(); - const { isOpen, setOpen } = useChat(); - const enabled = useAgentsEnabled(); - - if (!enabled || isOpen) return null; - - const comingSoonLabel = t("agents.coming_soon", "Coming soon"); - - return ( -
-
- {/* Left: Stirling content — only the button is interactive */} -
-
- -
- - {t("agents.stirling_full_name", "Stirling General Agent")} - - - {t( - "agents.stirling_long_description", - "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.", - )} - - -
- - {/* Right: 2×3 grid of coming-soon agents */} -
- {COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => ( - - - - ))} -
-
-
- ); -} diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 0c9cb314b..c60abe513 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -183,7 +183,6 @@ interface AiWorkflowResponse { interface ChatState { messages: ChatMessage[]; - isOpen: boolean; isLoading: boolean; progress: AiWorkflowProgress | null; /** Ordered log of every progress event in the current request. UI shows the last N entries. */ @@ -200,8 +199,6 @@ type ChatAction = | { type: "SET_LOADING"; loading: boolean } | { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null } | { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress } - | { type: "TOGGLE_OPEN" } - | { type: "SET_OPEN"; open: boolean } | { type: "CLEAR" }; function chatReducer(state: ChatState, action: ChatAction): ChatState { @@ -231,10 +228,6 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState { action.progress, ], }; - case "TOGGLE_OPEN": - return { ...state, isOpen: !state.isOpen }; - case "SET_OPEN": - return { ...state, isOpen: action.open }; case "CLEAR": return { ...state, @@ -363,13 +356,10 @@ async function consumeSSEStream( interface ChatContextValue { messages: ChatMessage[]; - isOpen: boolean; isLoading: boolean; progress: AiWorkflowProgress | null; /** Ordered log of every progress event for the current in-flight request. */ progressLog: AiWorkflowProgress[]; - toggleOpen: () => void; - setOpen: (open: boolean) => void; sendMessage: (content: string) => Promise; /** Abort any in-flight request and reset the chat to an empty conversation. */ clearChat: () => void; @@ -379,7 +369,6 @@ const ChatContext = createContext(null); const initialState: ChatState = { messages: [], - isOpen: false, isLoading: false, progress: null, progressLog: [], @@ -465,11 +454,6 @@ export function ChatProvider({ children }: { children: ReactNode }) { [fileActions, downloadFile], ); - const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []); - const setOpen = useCallback( - (open: boolean) => dispatch({ type: "SET_OPEN", open }), - [], - ); const clearChat = useCallback(() => { abortRef.current?.abort(); abortRef.current = null; @@ -649,12 +633,9 @@ export function ChatProvider({ children }: { children: ReactNode }) { { + // The overlay only mounts once the AI engine is enabled (config can load + // after first render), so re-measure when that flips true rather than only + // on initial mount, otherwise the default position never gets computed. + if (!enabled) return; const pos = getDefaultPos(); if (pos) setRndPos(pos); - }, []); + }, [enabled]); // Clear the reset timer on unmount to avoid state updates on dead components. // Also ensure body user-select is restored if we unmount mid-resize. @@ -140,6 +145,12 @@ export function ChatFAB() { { + // Fallback: ensure a position exists before opening, in case the + // layout effect measured before the overlay was laid out. + if (rndPos === null) { + const pos = getDefaultPos(); + if (pos) setRndPos(pos); + } setIsOpen(true); setHasUnviewedResult(false); }} diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index 62402108f..3f66123e9 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -35,7 +35,6 @@ transition: background 120ms ease-out, border-color 120ms ease-out; - /* Pair with .agent-button to morph between the card and the pill. */ view-transition-name: stirling-agent; } @@ -64,6 +63,36 @@ position: relative; } +/* Running / in-progress status dot, anchored to the agent pill icon. */ +.agent-status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--mantine-color-blue-5); + border: 1.5px solid var(--mantine-color-body); + animation: agent-dot-pulse 2.4s ease-in-out infinite; + pointer-events: none; +} + +@keyframes agent-dot-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-status-dot { + animation: none; + } +} + .chat-panel__agent-pill--loading { border-color: color-mix( in srgb, diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index bb672250d..67d796753 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -242,11 +242,6 @@ export default defineConfig(async ({ mode }) => { }, }, }, - resolve: { - alias: { - "@shared": path.resolve(__dirname, "../shared"), - }, - }, optimizeDeps: { exclude: ["@embedpdf/pdfium"], }, From 5fa5e12c648c239d25a852e75db88d058a8843e7 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:01:58 +0100 Subject: [PATCH 40/80] fix(saas): show team invitation banner in SaaS web build (#6612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a user is invited to a team, the SaaS web app shows **no invitation banner** — even though the pending invite is returned by `/api/v1/team/invitations/pending` on refresh. ## Root causes 1. **Never rendered in SaaS.** `TeamInvitationBanner` only existed in `desktop/`, wired solely into `DesktopBannerInitializer`. The SaaS banner stack rendered only ``. 2. **Single banner slot.** `BannerContext` holds one node; `setBanner` replaces it. `TrialStatusBanner` called `setBanner(null)` when there was no active trial (and re-fired once `trialStatus` resolved async), wiping any other banner. 3. **Shadowing was too fragile.** A first attempt shadowed the proprietary `UpgradeBannerInitializer` from the saas layer, but `vite-tsconfig-paths` resolves the `@app` specifier once at dev-server start — a newly-added shadow of an already-resolved module isn't picked up on a browser refresh, only a full restart. So the proprietary initializer kept running and no invite banner appeared (while the SaaS team context still fetched + populated the invite, which is why the pending call was visible). ## Fix - Add `saas/components/shared/TeamInvitationBanner.tsx` — ported from desktop, minus the desktop `connectionMode` gate and explicit billing refresh (SaaS `acceptInvitation` already refreshes credits + session). - Render it **inline in `saas/routes/Landing.tsx`** next to `GuestUserBanner` — a new import specifier in an existing file (HMR-friendly), unambiguously inside `SaaSTeamProvider`, mirroring the proven `GuestUserBanner` pattern. No dependency on the single banner slot. - **Remove `TrialStatusBanner`** (trials are being retired) so it can't clobber banners. Also drops the stale mention from the stripe-lazy-load test comment. ## Verification - `tsc --noEmit -p tsconfig.saas.vite.json`: clean in touched files; total unchanged from baseline (37 pre-existing, unrelated). - Manual: pull + verify the Accept/Decline banner appears for an account with a pending invite. --- .../tests/stubbed/stripe-lazy-load.spec.ts | 6 +- .../shared/TeamInvitationBanner.tsx | 133 +++++++++++++++++ .../components/shared/TrialStatusBanner.tsx | 136 ------------------ frontend/editor/src/saas/routes/Landing.tsx | 4 +- 4 files changed, 138 insertions(+), 141 deletions(-) create mode 100644 frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx delete mode 100644 frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx diff --git a/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts index 561f0d1bc..d74382aee 100644 --- a/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/stripe-lazy-load.spec.ts @@ -4,9 +4,9 @@ import { test, expect } from "@app/tests/helpers/stub-test-base"; * Verifies that the Stripe SDK (@stripe/stripe-js, @stripe/react-stripe-js, * and the js.stripe.com remote script) is NOT fetched on cold page loads — * only when the checkout modal actually mounts. The proprietary - * CheckoutProvider, the SaaS TrialStatusBanner, and the SaaS Plan settings - * page all gate the modal behind React.lazy + a conditional render so the - * Stripe chunk lives in its own async bundle. + * CheckoutProvider and the SaaS Plan settings page gate the modal behind + * React.lazy + a conditional render so the Stripe chunk lives in its own + * async bundle. */ const STRIPE_URL_FRAGMENTS = [ diff --git a/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx b/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx new file mode 100644 index 000000000..aa07d823d --- /dev/null +++ b/frontend/editor/src/saas/components/shared/TeamInvitationBanner.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { Button, Group, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { InfoBanner } from "@app/components/shared/InfoBanner"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; + +/** + * SaaS-web team invitation banner. Shown at the top of the app when the + * signed-in user has a pending invitation to join a team. + * + * Ported from the desktop banner, with two differences: there is no + * {@code connectionMode} gate (web is always SaaS), and there is no explicit + * billing refresh — {@link useSaaSTeam.acceptInvitation} already refreshes + * credits and the session after the team membership changes. + */ +export function TeamInvitationBanner() { + const { t } = useTranslation(); + const { receivedInvitations, acceptInvitation, rejectInvitation } = + useSaaSTeam(); + + const [processing, setProcessing] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const invitation = receivedInvitations[0]; // Show first invitation + + const handleAccept = async () => { + if (!invitation) return; + setProcessing(true); + try { + await acceptInvitation(invitation.invitationToken); + setDismissed(true); + } catch (error) { + console.error( + "[TeamInvitationBanner] Failed to accept invitation:", + error, + ); + } finally { + setProcessing(false); + } + }; + + const handleReject = async () => { + if (!invitation) return; + setProcessing(true); + try { + await rejectInvitation(invitation.invitationToken); + setDismissed(true); + } catch (error) { + console.error( + "[TeamInvitationBanner] Failed to reject invitation:", + error, + ); + } finally { + setProcessing(false); + } + }; + + const shouldShow = !dismissed && receivedInvitations.length > 0; + if (!shouldShow) return null; + + const message = ( + + {invitation.inviterEmail}{" "} + {t("team.invitationBanner.message", "has invited you to join")}{" "} + {invitation.teamName} + + ); + + const actionButtons = ( + + + + + ); + + return ( + + {message} + {actionButtons} + + } + show={shouldShow} + dismissible={false} + background="var(--mantine-color-dark-7)" + borderColor="var(--mantine-color-dark-5)" + textColor="rgba(255, 255, 255, 0.95)" + iconColor="rgba(255, 255, 255, 0.95)" + /> + ); +} diff --git a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx b/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx deleted file mode 100644 index 7efec40eb..000000000 --- a/frontend/editor/src/saas/components/shared/TrialStatusBanner.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { lazy, Suspense, useEffect, useState, useCallback } from "react"; -import { useBanner } from "@app/contexts/BannerContext"; -import { useAuth } from "@app/auth/UseSession"; -import { useTranslation } from "react-i18next"; -import { InfoBanner } from "@app/components/shared/InfoBanner"; -import { BASE_PATH } from "@app/constants/app"; - -const StripeCheckout = lazy( - () => import("@app/components/shared/StripeCheckoutSaas"), -); - -const SESSION_STORAGE_KEY = "trialBannerDismissed"; - -export function TrialStatusBanner() { - const { setBanner } = useBanner(); - const { t } = useTranslation(); - const { trialStatus } = useAuth(); - const [dismissed, setDismissed] = useState(() => { - return sessionStorage.getItem(SESSION_STORAGE_KEY) === "true"; - }); - const [checkoutOpen, setCheckoutOpen] = useState(false); - - // Only show banner during ACTIVE trial (not after expiration - modal handles that) - // Don't show if payment method already added (user has scheduled subscription) - const shouldShowBanner = - trialStatus && - trialStatus.isTrialing && // Only show during active trial - trialStatus.daysRemaining > 0 && // Trial hasn't expired yet - !trialStatus.hasPaymentMethod && - !trialStatus.hasScheduledSub && - !dismissed; - - if (trialStatus?.hasPaymentMethod || trialStatus?.hasScheduledSub) { - console.log("Subscription scheduled - hiding trial banner"); - } - - const handleOpenCheckout = useCallback(() => { - setCheckoutOpen(true); - }, []); - - const handleDismiss = useCallback(() => { - setDismissed(true); - sessionStorage.setItem(SESSION_STORAGE_KEY, "true"); - }, []); - - useEffect(() => { - if (!shouldShowBanner) { - setBanner(null); - return; - } - - const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString( - "en-GB", - { - month: "short", - day: "numeric", - }, - ); - - const message = t( - "plan.trial.message", - `Your trial ends in ${trialStatus.daysRemaining} day${trialStatus.daysRemaining !== 1 ? "s" : ""} (${trialEndDate}). Subscribe to continue Pro access.`, - { days: trialStatus.daysRemaining, date: trialEndDate }, - ); - - const logoIcon = ( - Stirling PDF - ); - - setBanner( - , - ); - - return () => { - setBanner(null); - }; - }, [ - shouldShowBanner, - trialStatus, - setBanner, - t, - handleOpenCheckout, - handleDismiss, - ]); - - const handleCheckoutSuccess = () => { - // Refresh to hide banner and show updated plan - window.location.reload(); - }; - - return ( - <> - {trialStatus && checkoutOpen && ( - - setCheckoutOpen(false)} - purchaseType="subscription" - planId="pro" - creditsPack={null} - planName="Pro" - onSuccess={handleCheckoutSuccess} - onError={(error) => console.error("Checkout error:", error)} - isTrialConversion={true} - /> - - )} - - ); -} diff --git a/frontend/editor/src/saas/routes/Landing.tsx b/frontend/editor/src/saas/routes/Landing.tsx index f82df010b..a3e65d1e8 100644 --- a/frontend/editor/src/saas/routes/Landing.tsx +++ b/frontend/editor/src/saas/routes/Landing.tsx @@ -6,7 +6,7 @@ import { isToolRoute } from "@app/utils/pathUtils"; import HomePage from "@app/pages/HomePage"; import Login from "@app/routes/Login"; import GuestUserBanner from "@app/components/auth/GuestUserBanner"; -import { TrialStatusBanner } from "@app/components/shared/TrialStatusBanner"; +import { TeamInvitationBanner } from "@app/components/shared/TeamInvitationBanner"; export default function Landing() { const { session, loading } = useAuth(); @@ -71,7 +71,7 @@ export default function Landing() { return ( <> - + ); From 9ee0bc4b32b1febb688f8a98a06e3dded37b48a3 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:12:01 +0100 Subject: [PATCH 41/80] Policies: enforce on upload or export (#6614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #6604 (merged). Builds the Security policy out so it actually enforces, driven from the editor. ## What it does - **Run on upload or export** — a single choice in the wizard: enforce when a file is uploaded, or just before it's exported. - **Output** — enforced result is a **new version** of the file (default) or a **new file**, with optional filename prefix/suffix/auto-number ("Output filename" subsection; auto-number only for new files). - **Export enforcement** — exporting an export-mode file runs the policy first and downloads the enforced result; never hard-blocks (on failure the original downloads). For new-version policies the in-editor file is versioned too. Covers every export path incl. multi-file ZIP. A toast (glowing in the policy's accent while it runs) reports progress and fades after ~10s. - **Affordances** — a freshly enforced file briefly glows its policy accent and carries a shield badge. - **Config tidy-up** — removed the unwired Security setting fields + the wizard's review step; "Upgrade to enterprise" on locked categories; category accent in the detail/wizard headers. ## Notes - Builds on the manual-only (client-driven) policy model from #6587 (`trigger: null`, metadata in `output.options`), adding the `runOn` field + export-time enforcement. - The page-editor merge-export (no single source file) enforces + downloads but doesn't version in place. ## Verification typecheck (core + proprietary), eslint, prettier; proprietary suite (105) green; flows checked in-app. --- .../core/components/fileEditor/FileEditor.tsx | 3 +- .../fileEditor/FileEditorThumbnail.tsx | 3 +- .../components/pageEditor/FileThumbnail.tsx | 3 +- .../components/shared/FileSidebarFileItem.css | 26 +++ .../components/shared/FileSidebarFileItem.tsx | 15 +- .../core/components/shared/WorkbenchBar.tsx | 4 +- .../core/components/toast/ToastRenderer.css | 21 ++ .../core/components/toast/ToastRenderer.tsx | 12 +- .../editor/src/core/components/toast/types.ts | 4 + .../src/core/services/downloadService.ts | 3 + .../src/core/services/exportWithPolicy.ts | 29 +++ .../src/core/services/pdfExportService.ts | 6 +- .../editor/src/core/services/policyExport.ts | 12 ++ .../editor/src/core/utils/downloadUtils.ts | 22 +- .../components/policies/Policies.css | 28 +++ .../policies/PoliciesSidebar.test.tsx | 6 +- .../components/policies/PoliciesSidebar.tsx | 4 +- .../components/policies/PolicySetupWizard.tsx | 144 ++++++++----- .../components/policies/PolicyToolConfig.tsx | 5 +- .../components/policies/usePolicyAutoRun.ts | 13 +- .../data/policyDefinitions.test.ts | 4 +- .../proprietary/data/policyDefinitions.tsx | 26 +-- .../src/proprietary/hooks/usePolicies.test.ts | 1 + .../src/proprietary/hooks/usePolicies.ts | 3 + .../proprietary/hooks/usePolicyFileBadges.ts | 8 + .../src/proprietary/services/policyBackend.ts | 1 + .../src/proprietary/services/policyExport.ts | 192 ++++++++++++++++++ .../services/policyPipeline.test.ts | 3 +- .../proprietary/services/policyPipeline.ts | 45 ++-- .../src/proprietary/services/policyStorage.ts | 2 + .../editor/src/proprietary/types/policies.ts | 4 + 31 files changed, 529 insertions(+), 123 deletions(-) create mode 100644 frontend/editor/src/core/services/exportWithPolicy.ts create mode 100644 frontend/editor/src/core/services/policyExport.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.ts diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx index 3ddd5ca40..ed8944fb0 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.tsx @@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard"; import FilePickerModal from "@app/components/shared/FilePickerModal"; import { FileId, StirlingFile } from "@app/types/fileContext"; import { alert } from "@app/components/toast"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { @@ -256,6 +256,7 @@ const FileEditor = ({ data: file, filename: file.name, localPath: record.localFilePath, + fileId, }); console.log("[FileEditor] Download complete, checking dirty state:", { localFilePath: record.localFilePath, diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index 0b78d4e26..632d9ea00 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain"; import HoverActionMenu, { HoverAction, } from "@app/components/shared/HoverActionMenu"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import UploadToServerModal from "@app/components/shared/UploadToServerModal"; import ShareFileModal from "@app/components/shared/ShareFileModal"; @@ -248,6 +248,7 @@ const FileEditorThumbnail = ({ data: fileToSave, filename: file.name, localPath: file.localFilePath, + fileId: file.id, }); if (!result.cancelled && result.savedPath) { fileActions.updateStirlingFileStub(file.id, { diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 0356aa6c6..93ff3c2ab 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -23,7 +23,7 @@ import { FileId } from "@app/types/file"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; interface FileItem { id: FileId; @@ -98,6 +98,7 @@ const FileThumbnail = ({ void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download", + fileId: file.id, }); return; } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 81ca24ae0..02e6de971 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -36,6 +36,32 @@ background-color: rgba(59, 130, 246, 0.06); } +/* A policy-enforced file glows in that policy's accent colour so it's obvious + the file has had a policy applied: it pulses a few times to catch the eye and + then fades out (no lingering glow). */ +.file-sidebar-file-item.policy-enforced { + animation: policy-glow-fade 4.5s ease-in-out forwards; +} +@keyframes policy-glow-fade { + 0%, + 24%, + 48% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent), + 0 0 6px -2px var(--policy-glow); + } + 12%, + 36%, + 60% { + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent), + 0 0 16px 0 var(--policy-glow); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + .file-sidebar-file-item.selected { background-color: rgba(59, 130, 246, 0.12); } diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index 1b2f566f1..6c5ecaba3 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -132,6 +132,9 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; + /** True only just after the policy was applied — drives the one-off glow, so + * it doesn't replay on every reload of an already-enforced file. */ + recent: boolean; } export interface FileItemProps { @@ -201,6 +204,9 @@ export function FileItem({ const handleMouseLeave = useCallback(() => setHoverRect(null), []); + // A just-applied policy (recent run) drives the one-off row glow. + const recentPolicy = policies.find((p) => p.recent); + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready const thumbPos = hoverRect && resolvedThumbnail @@ -214,7 +220,14 @@ export function FileItem({ <>
onClick(fileId)} draggable={draggable} onDragStart={ diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 6b35a7fd3..2f4cb7299 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; import { Tooltip } from "@app/components/shared/Tooltip"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; import { WorkbenchBarButtonConfig, WorkbenchBarRenderContext, @@ -150,6 +150,7 @@ export default function WorkbenchBar({ data: new Blob([buffer], { type: "application/pdf" }), filename: fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, + fileId: stub?.id, }); if (!forceNewFile && !result.cancelled && stub && result.savedPath) { fileActions.updateStirlingFileStub(stub.id, { @@ -179,6 +180,7 @@ export default function WorkbenchBar({ data: file, filename: file.name, localPath: forceNewFile ? undefined : stub?.localFilePath, + fileId: stub?.id, }); if (result.cancelled) continue; if (!forceNewFile && stub && result.savedPath) { diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.css b/frontend/editor/src/core/components/toast/ToastRenderer.css index 0cf2aac09..6ecd9e3df 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.css +++ b/frontend/editor/src/core/components/toast/ToastRenderer.css @@ -51,6 +51,27 @@ pointer-events: auto; } +/* A toast tied to a policy pulses a glow in that policy's accent (var set + inline as --toast-glow), so it's obvious which policy is being applied. */ +.toast-item--glow { + animation: toast-glow-pulse 1.8s ease-in-out infinite; +} +@keyframes toast-glow-pulse { + 0%, + 100% { + box-shadow: + var(--shadow-lg), + 0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent), + 0 0 10px -2px var(--toast-glow); + } + 50% { + box-shadow: + var(--shadow-lg), + 0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent), + 0 0 20px 0 var(--toast-glow); + } +} + /* Toast Alert Type Colors */ .toast-item--success { background: var(--color-green-100); diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.tsx index 7c8f40395..c13010e27 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.tsx +++ b/frontend/editor/src/core/components/toast/ToastRenderer.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { useToast } from "@app/components/toast/ToastContext"; import { ToastInstance, ToastLocation } from "@app/components/toast/types"; @@ -64,7 +65,16 @@ export default function ToastRenderer() {
{grouped[loc].map((t) => { return ( -
+
{/* Top row: Icon + Title + Controls */}
{/* Icon */} diff --git a/frontend/editor/src/core/components/toast/types.ts b/frontend/editor/src/core/components/toast/types.ts index 1377cda4b..1edbf62fb 100644 --- a/frontend/editor/src/core/components/toast/types.ts +++ b/frontend/editor/src/core/components/toast/types.ts @@ -25,6 +25,10 @@ export interface ToastOptions { id?: string; /** If true, show chevron and collapse/expand animation. Defaults to true. */ expandable?: boolean; + /** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set, + * the toast pulses a glow in this colour — used to tie a toast to a policy's + * accent. */ + glowColor?: string; } export interface ToastInstance extends Omit< diff --git a/frontend/editor/src/core/services/downloadService.ts b/frontend/editor/src/core/services/downloadService.ts index db728e66d..c61015f36 100644 --- a/frontend/editor/src/core/services/downloadService.ts +++ b/frontend/editor/src/core/services/downloadService.ts @@ -2,6 +2,9 @@ export interface DownloadRequest { data: Blob | File; filename: string; localPath?: string; + /** Workspace fileId of the file being exported, when known. Lets export-time + * policy enforcement version the in-editor file (not just the download). */ + fileId?: string; } export interface DownloadResult { diff --git a/frontend/editor/src/core/services/exportWithPolicy.ts b/frontend/editor/src/core/services/exportWithPolicy.ts new file mode 100644 index 000000000..207e37d78 --- /dev/null +++ b/frontend/editor/src/core/services/exportWithPolicy.ts @@ -0,0 +1,29 @@ +/** + * Export entry points call {@link downloadFileWithPolicy} instead of + * {@link downloadFile} so any "export"-triggered policy enforces on the file + * before it's downloaded. The enforcement itself is proprietary (a no-op in the + * core build via the `@app/services/policyExport` stub), and never hard-blocks: + * on failure the original file is downloaded. + */ + +import { + downloadFile, + type DownloadRequest, + type DownloadResult, +} from "@app/services/downloadService"; +import { enforceExportPolicies } from "@app/services/policyExport"; + +export async function downloadFileWithPolicy( + request: DownloadRequest, +): Promise { + // enforceExportPolicies only touches PDFs and is a no-op without an active + // export policy, so non-PDF / non-policy downloads pass straight through. + const input = + request.data instanceof File + ? request.data + : new File([request.data], request.filename, { + type: request.data.type, + }); + const [enforced] = await enforceExportPolicies([input], [request.fileId]); + return downloadFile({ ...request, data: enforced ?? request.data }); +} diff --git a/frontend/editor/src/core/services/pdfExportService.ts b/frontend/editor/src/core/services/pdfExportService.ts index dd605d481..da7bbc9a0 100644 --- a/frontend/editor/src/core/services/pdfExportService.ts +++ b/frontend/editor/src/core/services/pdfExportService.ts @@ -7,7 +7,7 @@ import { setPageRotation, addNewPage, } from "@app/services/pdfiumService"; -import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; import { PDFDocument, PDFPage } from "@app/types/pageEditor"; // A4 dimensions in PDF points (72 dpi) @@ -259,10 +259,10 @@ export class PDFExportService { } /** - * Download a single file + * Download a single file, applying any export-triggered policy first. */ downloadFile(blob: Blob, filename: string): void { - void downloadFile({ data: blob, filename }); + void downloadFileWithPolicy({ data: blob, filename }); } /** diff --git a/frontend/editor/src/core/services/policyExport.ts b/frontend/editor/src/core/services/policyExport.ts new file mode 100644 index 000000000..dc23a4093 --- /dev/null +++ b/frontend/editor/src/core/services/policyExport.ts @@ -0,0 +1,12 @@ +/** + * Open-source stub for export-time policy enforcement. Policies are a proprietary + * feature, so in the core build there's nothing to enforce — this returns the + * files unchanged. The proprietary build shadows this module via the `@app/*` + * alias with the real implementation. + */ +export async function enforceExportPolicies( + files: File[], + _fileIds?: (string | undefined)[], +): Promise { + return files; +} diff --git a/frontend/editor/src/core/utils/downloadUtils.ts b/frontend/editor/src/core/utils/downloadUtils.ts index b4af832a2..0f25935b3 100644 --- a/frontend/editor/src/core/utils/downloadUtils.ts +++ b/frontend/editor/src/core/utils/downloadUtils.ts @@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext"; import { fileStorage } from "@app/services/fileStorage"; import { zipFileService } from "@app/services/zipFileService"; import { downloadFile } from "@app/services/downloadService"; +import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; +import { enforceExportPolicies } from "@app/services/policyExport"; /** * Downloads a blob as a file using browser download API @@ -27,10 +29,11 @@ export async function downloadFileFromStorage( throw new Error(`File "${file.name}" not found in storage`); } - await downloadFile({ + await downloadFileWithPolicy({ data: stirlingFile, filename: stirlingFile.name, localPath: file.localFilePath, + fileId: file.id, }); } @@ -59,15 +62,15 @@ export async function downloadFilesAsZip( throw new Error("No files provided for ZIP download"); } - // Convert stored files to File objects + // Convert stored files to File objects (tracking ids so export policies can + // version the in-editor file). const filesToZip: File[] = []; + const fileIds: (string | undefined)[] = []; for (const fileWithUrl of files) { - const lookupKey = fileWithUrl.id; - const stirlingFile = await fileStorage.getStirlingFile(lookupKey); - + const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id); if (stirlingFile) { - // StirlingFile is already a File object! filesToZip.push(stirlingFile); + fileIds.push(fileWithUrl.id); } } @@ -75,6 +78,9 @@ export async function downloadFilesAsZip( throw new Error("No valid files found in storage for ZIP download"); } + // Enforce any export-triggered policy on each PDF before they're zipped. + const enforced = await enforceExportPolicies(filesToZip, fileIds); + // Generate default filename if not provided const finalZipFilename = zipFilename || @@ -82,7 +88,7 @@ export async function downloadFilesAsZip( // Create and download ZIP const { zipFile } = await zipFileService.createZipFromFiles( - filesToZip, + enforced, finalZipFilename, ); await downloadFile({ data: zipFile, filename: finalZipFilename }); @@ -125,7 +131,7 @@ export async function downloadFiles( * @param filename - Optional custom filename */ export function downloadFileObject(file: File, filename?: string): void { - void downloadFile({ data: file, filename: filename || file.name }); + void downloadFileWithPolicy({ data: file, filename: filename || file.name }); } /** diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index fc6e24324..4102183a5 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -222,6 +222,34 @@ margin: 0 0 var(--space-2); } +/* Sub-section header inside a settings card (e.g. "Output filename"). The field + directly below it carries data-first so the borders don't double up. */ +.pol-subhead { + padding: 0.55rem 0.875rem 0.4rem; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-4); + background: var(--color-surface); + border-top: 1px solid var(--color-border); +} + +/* Position dropdown + custom-text input sitting on one row under the + "Output filename" subhead — the dropdown sizes to content, text fills. */ +.pol-name-row { + display: flex; + gap: var(--space-2); + align-items: center; +} +.pol-name-row > :first-child { + flex: 0 0 auto; +} +.pol-name-row > :last-child { + flex: 1 1 auto; + min-width: 0; +} + /* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */ .pol-field { padding: 0.7rem 0.875rem; diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx index 0eabd502f..cc34c5e5e 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.test.tsx @@ -112,14 +112,14 @@ describe("Policies right-sidebar surface", () => { } }); - it("shows Security as active and the unbuilt categories as Coming soon", () => { + it("shows Security as active and the unbuilt categories as upgrade-gated", () => { renderHost(); expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1); // Ingestion, Compliance, Routing, Retention are locked for this release. - expect(screen.getAllByText("Coming soon")).toHaveLength(4); + expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4); }); - it("does not open a Coming soon policy when its row is clicked", () => { + it("does not open an upgrade-gated policy when its row is clicked", () => { renderHost(); fireEvent.click(screen.getByText("Ingestion")); // The locked row isn't a button — we stay on the list, nothing opens. diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 2028063fe..5f401167c 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -151,7 +151,9 @@ export function PoliciesSection({ {cat.label} - Coming soon + + Upgrade to enterprise +
); diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index 2231d5112..a36adc1d5 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -80,8 +80,7 @@ interface PolicySetupWizardProps { * The shared policy wizard, used for both setup and edit. Two steps: Workflow * (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy * fields + output/retry config). The workflow builder is kept mounted across - * steps so the final action can trigger its save. (A Sources step exists in - * code, gated off by SOURCES_IN_FLOW, for when non-editor sources return.) + * steps so the final action can trigger its save. */ export function PolicySetupWizard({ category, @@ -113,8 +112,8 @@ export function PolicySetupWizard({ ); const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0); const [scopeTypes, setScopeTypes] = useState(initial.scopeTypes); - // Reviewer is no longer configured in the flow (there's no human-review step), - // but the field is kept in the saved policy, defaulted to the signed-in user. + // Reviewer isn't shown in the flow; the field is still saved on the policy, + // defaulted to the signed-in user. const reviewerEmail = initial.reviewerEmail || user?.email || ""; // Output + retry settings — the real, working folder settings (the engine // applies them). Pre-filled from the backing folder in edit mode. @@ -129,6 +128,10 @@ export function PolicySetupWizard({ const [retryDelayMinutes, setRetryDelayMinutes] = useState( initialFolder?.retryDelayMinutes ?? 5, ); + // The editor event this policy runs on: input on upload, or output on export. + const [runOn, setRunOn] = useState<"upload" | "export">( + initial.runOn ?? "upload", + ); const workflowSave = useRef<(() => void) | null>(null); const [submitting, setSubmitting] = useState(false); const [saveError, setSaveError] = useState(null); @@ -197,6 +200,7 @@ export function PolicySetupWizard({ scopeTypes: scopeNarrow ? scopeTypes : [], reviewerEmail, folder: { + runOn, outputMode, outputName: outputName.trim(), outputNamePosition, @@ -229,6 +233,7 @@ export function PolicySetupWizard({ scopeTypes: scopeNarrow ? scopeTypes : [], reviewerEmail, folder: { + runOn, outputMode, outputName: outputName.trim(), outputNamePosition, @@ -329,35 +334,69 @@ export function PolicySetupWizard({ {step === 2 && ( <>

{category.desc}

- - {config.fields.map((f, i) => ( - - setFieldValues((prev) => ({ ...prev, [f.key]: v })) - } - /> - ))} - + {config.fields.length > 0 && ( + + {config.fields.map((f, i) => ( + + setFieldValues((prev) => ({ ...prev, [f.key]: v })) + } + /> + ))} + + )} {/* Real, working output + retry settings (applied by the engine). */}

Output & retries

+ {/* The editor event the policy runs on: input on upload, or + output on export (enforced before the file is exported). */} +
Run on
+ setRunOn(e.target.value as "upload" | "export") + } + aria-label="Run on" + options={[ + { value: "upload", label: "Upload" }, + { value: "export", label: "Export" }, + ]} + /> + } + /> +
+
Output
+
+ - setOutputMode( - e.target.value as "new_file" | "new_version", - ) - } + onChange={(e) => { + const mode = e.target.value as + | "new_file" + | "new_version"; + setOutputMode(mode); + // Auto-number only applies to new files; a new version + // replaces the file in place, so fall back to suffix. + if ( + mode === "new_version" && + outputNamePosition === "auto-number" + ) { + setOutputNamePosition("suffix"); + } + }} aria-label="Output mode" options={[ { value: "new_file", label: "New file" }, @@ -367,41 +406,40 @@ export function PolicySetupWizard({ } />
-
- - setOutputNamePosition( - e.target.value as "prefix" | "suffix" | "auto-number", - ) - } - aria-label="Add to filename" - options={[ - { value: "prefix", label: "Prefix" }, - { value: "suffix", label: "Suffix" }, - { value: "auto-number", label: "Auto-number" }, - ]} - /> - } - /> -
-
- Output filename
+
+
+ setOutputName(e.target.value)} - placeholder="optional" - aria-label="Text to add" + placeholder="Text to add (optional)" + aria-label="Filename text" /> - } - /> + )} +
{tool.enabled && (tool.operation === "redact" ? ( - // Redact config is reduced to just the PII type picker.
) : tool.operation === "watermark" ? ( - // Watermark: full settings minus the "Flatten PDF pages to - // images" toggle (hidden), with flatten forced on. + // Watermark settings with flatten hidden + forced on (see + // PolicyWatermarkConfig).
{ if (!POLICIES_ENABLED) return; const active = Object.entries(policies).filter( - ([, s]) => s.configured && s.status === "active" && s.backendId, + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + // Only auto-run on upload when the policy is set to run on upload + // (export-triggered policies enforce at export time instead). + (s.runOn ?? "upload") === "upload", ); for (const [categoryId, s] of active) { for (const stub of fileStubs) { const key = dispatchKey(categoryId, stub.id); - // Skip if already run (persisted) or a dispatch is mid-flight (this - // session) — runPolicyOnFile now waits for the file's bytes to commit, - // so the in-memory guard, not an eager mark, prevents double-firing. + // Skip if already run (persisted) or a dispatch is in flight — the + // in-memory guard prevents double-firing during the async wait. if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) { continue; } diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts index c3e1e6003..7ca0c28e8 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.test.ts +++ b/frontend/editor/src/proprietary/data/policyDefinitions.test.ts @@ -5,7 +5,9 @@ describe("policy definitions integrity", () => { it("every category has a matching config entry", () => { for (const cat of POLICY_CATEGORIES) { expect(POLICY_CONFIG[cat.id], `config for ${cat.id}`).toBeDefined(); - expect(POLICY_CONFIG[cat.id].fields.length).toBeGreaterThan(0); + // A category may have no policy-level setting fields (e.g. Security's were + // all unwired and removed); fields is required but can be empty. + expect(Array.isArray(POLICY_CONFIG[cat.id].fields)).toBe(true); expect(POLICY_CONFIG[cat.id].rules.length).toBeGreaterThan(0); // Every preset seeds a real, non-empty pipeline (the category→steps map). expect( diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index 07601a0d3..64fe0abb8 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -200,29 +200,9 @@ export const POLICY_CONFIG: Record = { }, ], scopeLabel: "All PDFs on this device", - // Policy-level controls only — detection/encryption/signing/watermark are - // per-tool and now live in the Workflow step. - fields: [ - { - label: "User can override", - key: "userOverride", - type: "toggle", - value: true, - }, - { - label: "Default access level", - key: "defaultAccess", - type: "select", - value: "Restricted", - options: ["Open", "Restricted", "Confidential", "Top Secret"], - }, - { - label: "Block external sharing", - key: "blockExternal", - type: "toggle", - value: false, - }, - ], + // No policy-level setting fields: tool config lives in the Workflow step; + // output naming + retries are set in the wizard. + fields: [], }, compliance: { summary: diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts index 6563c48b9..1a05591c4 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.test.ts @@ -52,6 +52,7 @@ const wizardResult = { scopeTypes: [], reviewerEmail: "reviewer@x.com", folder: { + runOn: "upload" as const, outputMode: "new_file" as const, outputName: "", outputNamePosition: "prefix" as const, diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 8e954b23f..1e5019248 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -130,6 +130,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], @@ -165,6 +166,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], @@ -228,6 +230,7 @@ export function usePolicies() { reviewerEmail: result.reviewerEmail, outputMode: result.folder.outputMode, outputName: result.folder.outputName, + runOn: result.folder.runOn, }); }, [], diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 6afc43a48..563f49161 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -4,6 +4,11 @@ import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { ROW_ACCENT } from "@app/components/policies/policyStatus"; import type { FileItemPolicyRef } from "@app/components/shared/FileSidebarFileItem"; +/** How long after a run a badge counts as "recent" (drives the one-off glow). + * Covers the run + import delay; old/reloaded runs fall outside it, so the glow + * fires only just after a policy is applied, not on every page reload. */ +const RECENT_MS = 60_000; + /** Policy accent name (ROW_ACCENT) → the CSS colour var the badge uses. */ const ACCENT_VAR: Record = { blue: "var(--color-blue)", @@ -26,10 +31,12 @@ export function usePolicyFileBadges(): Map { const labelById = new Map( loadPolicyCatalog().categories.map((c) => [c.id, c.label]), ); + const now = Date.now(); const byFile = new Map(); for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; + const recent = now - run.startedAt < RECENT_MS; for (const fileId of run.outputFileIds ?? []) { const list = byFile.get(fileId) ?? []; if (!list.some((p) => p.id === run.categoryId)) { @@ -37,6 +44,7 @@ export function usePolicyFileBadges(): Map { id: run.categoryId, name, accentColor: ACCENT_VAR[ROW_ACCENT[run.categoryId] ?? "blue"], + recent, }); byFile.set(fileId, list); } diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index 77ff6ecbd..5b3097e02 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -53,6 +53,7 @@ export function decodedToState( fieldValues: decoded.fieldValues, outputMode: decoded.folder.outputMode, outputName: decoded.folder.outputName, + runOn: decoded.folder.runOn, folderId: localFolderId, backendId: decoded.id, // Catalog-category policies are built-in defaults (not deletable). diff --git a/frontend/editor/src/proprietary/services/policyExport.ts b/frontend/editor/src/proprietary/services/policyExport.ts new file mode 100644 index 000000000..c4c712e3c --- /dev/null +++ b/frontend/editor/src/proprietary/services/policyExport.ts @@ -0,0 +1,192 @@ +/** + * Export-time policy enforcement. A policy whose `runOn` is "export" runs its + * pipeline on a file just before it leaves the editor; the enforced result is + * what gets exported. Core export handlers reach this via the `@app/*` alias + * (the open-source build ships a no-op stub). + * + * Export is never hard-blocked: on failure the original file is exported and a + * warning toast is shown. For "new version" policies, the run is also recorded + * so the mounted import effect versions the in-editor file, not just the + * download. Only PDFs are enforced. + */ + +import { loadPolicies } from "@app/services/policyStorage"; +import { loadPolicyCatalog } from "@app/services/policyCatalog"; +import { + runStoredPolicy, + getPolicyRun, + downloadPolicyOutput, +} from "@app/services/policyApi"; +import { recordRunStart } from "@app/components/policies/policyRunStore"; +import { ROW_ACCENT } from "@app/components/policies/policyStatus"; +import { alert, updateToast, dismissToast } from "@app/components/toast"; +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; + +/** Poll cadence + cap for a single export run (≈2.5 min worst case). */ +const POLL_MS = 2000; +const MAX_POLLS = 75; +/** How long the result toast lingers before fading out. */ +const TOAST_LINGER_MS = 10_000; +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const isPdf = (f: File) => + f.type === "application/pdf" || /\.pdf$/i.test(f.name); + +interface ExportPolicy { + categoryId: string; + backendId: string; + label: string; + outputMode: "new_file" | "new_version"; + /** The policy's accent as a CSS colour, for the toast glow. */ + accent: string; +} + +interface PolicyRunResult { + file: File; + runId: string; + outputs: { fileId: string; fileName: string }[]; +} + +/** Configured, active policies set to enforce on export (read from the cache). */ +function activeExportPolicies(): ExportPolicy[] { + if (!POLICIES_ENABLED) return []; + const labels = new Map( + loadPolicyCatalog().categories.map((c) => [c.id, c.label]), + ); + return Object.entries(loadPolicies()) + .filter( + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + s.runOn === "export", + ) + .map(([id, s]) => ({ + categoryId: id, + backendId: s.backendId as string, + label: labels.get(id) ?? "Policy", + outputMode: s.outputMode === "new_file" ? "new_file" : "new_version", + accent: `var(--color-${ROW_ACCENT[id] ?? "blue"})`, + })); +} + +/** Run one policy on a file and resolve the enforced bytes + run info (throws on + * failure). */ +async function runToCompletion( + backendId: string, + file: File, +): Promise { + const runId = await runStoredPolicy(backendId, [file]); + for (let i = 0; i < MAX_POLLS; i++) { + await delay(POLL_MS); + let view; + try { + view = await getPolicyRun(runId); + } catch { + continue; // transient — keep polling within the cap. + } + if (view.status === "COMPLETED") { + const out = view.outputs?.[0]; + if (!out) throw new Error("policy produced no output"); + const blob = await downloadPolicyOutput(out.fileId); + // Keep the export's filename; only the bytes are the enforced result. + const enforced = new File([blob], file.name, { + type: blob.type || file.type || "application/pdf", + }); + return { file: enforced, runId, outputs: view.outputs ?? [] }; + } + if (view.status === "FAILED" || view.status === "CANCELLED") { + throw new Error(view.error || `policy run ${view.status.toLowerCase()}`); + } + } + throw new Error("policy run timed out"); +} + +/** + * Enforce every active export-policy on each PDF just before export, returning + * the files in order (enforced, or the original on failure). `fileIds[i]` is the + * workspace id of `files[i]` when known — used to version the in-editor file for + * "new version" policies. Non-PDFs pass through untouched, and a single toast + * (glowing in the policy's accent while it runs) fades a few seconds after the + * result. + */ +export async function enforceExportPolicies( + files: File[], + fileIds?: (string | undefined)[], +): Promise { + const active = activeExportPolicies(); + const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : [])); + if (!active.length || targets.length === 0) return files; + + const names = active.map((p) => p.label).join(", "); + const toastId = alert({ + alertType: "neutral", + title: `Applying ${names}`, + body: `Enforcing ${ + targets.length === 1 ? "your file" : `${targets.length} files` + } before export…`, + isPersistentPopup: true, + expandable: false, + glowColor: active[0].accent, + }); + + const out = [...files]; + let failures = 0; + for (const i of targets) { + const file = files[i]; + const fileId = fileIds?.[i]; + try { + let current = file; + // The last "new version" policy's output is what versions the editor file + // (recording every policy would double-consume the same input). + let versionRun: PolicyRunResult & { categoryId: string }; + let hasVersionRun = false; + for (const policy of active) { + const result = await runToCompletion(policy.backendId, current); + current = result.file; + if (policy.outputMode === "new_version" && fileId) { + versionRun = { ...result, categoryId: policy.categoryId }; + hasVersionRun = true; + } + } + out[i] = current; + if (hasVersionRun && fileId) { + recordRunStart({ + runId: versionRun!.runId, + categoryId: versionRun!.categoryId, + fileId, + fileName: file.name, + fileSize: file.size, + status: "COMPLETED", + outputs: versionRun!.outputs, + error: null, + startedAt: Date.now(), + }); + } + } catch { + failures += 1; // leave out[i] as the original — never hard-block. + } + } + + updateToast( + toastId, + failures + ? { + alertType: "warning", + title: "Exported without full enforcement", + body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`, + isPersistentPopup: false, + glowColor: undefined, + } + : { + alertType: "success", + title: `${names} applied`, + body: "Enforced before export.", + isPersistentPopup: false, + glowColor: undefined, + }, + ); + // update() doesn't reschedule auto-dismiss, so fade the result out explicitly. + window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS); + return out; +} diff --git a/frontend/editor/src/proprietary/services/policyPipeline.test.ts b/frontend/editor/src/proprietary/services/policyPipeline.test.ts index 9b0315eac..e6017e99b 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.test.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.test.ts @@ -116,6 +116,7 @@ const samplePolicy = { reviewerEmail: "me@x.com", fieldValues: { minConfidence: "80%" }, folder: { + runOn: "export" as const, outputMode: "new_version" as const, outputName: "secured", outputNamePosition: "suffix" as const, @@ -133,7 +134,7 @@ describe("buildBackendPolicy", () => { expect(policy.steps).toEqual([ { operation: "/api/v1/misc/compress-pdf", parameters: {} }, ]); - // Trigger is null (browser-driven); extras ride in output.options. + // Manual-only policy: no server-side trigger, extras ride in output.options. expect(policy.trigger).toBeNull(); expect(policy.output.options.categoryId).toBe("security"); expect(policy.output.options.reviewerEmail).toBe("me@x.com"); diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index abbd28dd9..29f38695a 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -35,7 +35,7 @@ export interface BackendPipelineDefinition { output: BackendOutputSpec; } -/** A policy's automatic trigger ("schedule" | "folder-watch"); null means manual (run on demand). */ +/** How a stored policy is triggered ("manual" | "folder" | "schedule" | "s3"). */ export interface BackendTriggerConfig { type: string; options: Record; @@ -49,7 +49,8 @@ export interface BackendPolicy { owner: string; /** Gates automatic triggering; an explicit run ignores it. */ enabled: boolean; - /** Null = manual; these policies are run from the browser on uploaded files. */ + /** Null for a manual-only (client-driven) policy — the editor fires runs on + * upload/export via /run, so there's no server-side trigger. */ trigger: BackendTriggerConfig | null; steps: BackendPipelineStep[]; output: BackendOutputSpec; @@ -189,7 +190,7 @@ export interface PolicyToStore { /** The decoded policy read back from the backend. */ export interface DecodedPolicy { id: string; - /** The catalog category this policy maps to (from output.options.categoryId). */ + /** The catalog category this policy maps to (from trigger.options.categoryId). */ categoryId: string; name: string; enabled: boolean; @@ -203,6 +204,7 @@ export interface DecodedPolicy { } const DEFAULT_FOLDER: PolicyFolderSettings = { + runOn: "upload", outputMode: "new_version", outputName: "", outputNamePosition: "prefix", @@ -211,11 +213,16 @@ const DEFAULT_FOLDER: PolicyFolderSettings = { }; /** - * Map a frontend policy to the backend {@link BackendPolicy} for persistence. Trigger is null: - * these policies are run from the browser on uploaded files, not fired by a backend trigger. The - * backend models only name/enabled/trigger/steps/output, so all policy-level extras (categoryId, - * sources, scope, reviewer, fields, output/retry settings, and the full automation for a lossless - * UI round-trip) ride in `output.options`; `steps` carries the endpoint-mapped pipeline. + * Map a frontend policy to the backend {@link BackendPolicy} for persistence. + * Policies are manual-only (client-driven): the editor fires runs on upload / + * before export via /run, so `trigger` is null (a server-side folder-watch or + * schedule trigger doesn't fit the in-editor model, and a null trigger skips + * trigger validation on the backend). The backend models only + * name/enabled/trigger/steps/output, so the policy-level extras (categoryId, + * sources, scope, reviewer, fields) and the output + retry settings all ride in + * `output.options`; the full frontend automation is stashed in + * `output.options.automation` for a lossless UI round-trip (while `steps` + * carries the endpoint-mapped pipeline the engine runs, pre-built by the caller). */ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { return { @@ -234,6 +241,8 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { maxRetries: input.folder.maxRetries, retryDelayMinutes: input.folder.retryDelayMinutes, automation: input.automation, + runOn: input.folder.runOn, + // Policy-level metadata (no trigger bag to hold it any more). categoryId: input.categoryId, sources: input.sources, scopeTypes: input.scopeTypes, @@ -247,27 +256,29 @@ export function buildBackendPolicy(input: PolicyToStore): BackendPolicy { /** Decode a stored backend policy back into the frontend settings. */ export function fromBackendPolicy(policy: BackendPolicy): DecodedPolicy { const output = policy.output.options; + // Metadata lives in output.options; legacy records kept it in trigger.options, + // so merge both (output wins) to decode either shape. + const meta = { ...(policy.trigger?.options ?? {}), ...output }; const str = (v: unknown, fallback = "") => typeof v === "string" ? v : fallback; const num = (v: unknown, fallback: number) => typeof v === "number" ? v : fallback; return { id: policy.id, - categoryId: str(output.categoryId), + categoryId: str(meta.categoryId), name: policy.name, enabled: policy.enabled, automation: (output.automation as AutomationConfig | undefined) ?? null, - sources: Array.isArray(output.sources) ? (output.sources as string[]) : [], - scopeTypes: Array.isArray(output.scopeTypes) - ? (output.scopeTypes as string[]) + sources: Array.isArray(meta.sources) ? (meta.sources as string[]) : [], + scopeTypes: Array.isArray(meta.scopeTypes) + ? (meta.scopeTypes as string[]) : [], - reviewerEmail: str(output.reviewerEmail), + reviewerEmail: str(meta.reviewerEmail), fieldValues: - (output.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, + (meta.fieldValues as DecodedPolicy["fieldValues"] | undefined) ?? {}, folder: { - // Default to versioning unless the stored policy explicitly says new_file, - // so a missing/legacy output.mode follows the new-version default rather - // than silently flipping a reconciled policy to spawning separate files. + runOn: meta.runOn === "export" ? "export" : "upload", + // Legacy/missing output.mode defaults to new_version, not new_file. outputMode: output.mode === "new_file" ? "new_file" : "new_version", outputName: str(output.name), outputNamePosition: diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index 87616555c..20db264e4 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -26,6 +26,8 @@ function defaultState(): PolicyState { outputMode: "new_version", // No rename by default — the output keeps the input's filename. outputName: "", + // Enforce on upload by default; export enforcement is the alternative. + runOn: "upload", // Every catalog category is a shipped, built-in policy → default (not // deletable). User-created policies (later) will set this false. isDefault: true, diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index 7cc5dc27c..9c0bf0f0b 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -132,6 +132,8 @@ export interface PolicyState { * input's filename; when set, it's applied as a prefix/suffix per the policy's * name-position setting. */ outputName?: string; + /** When the policy runs: on "upload" or before "export". Defaults to "upload". */ + runOn?: "upload" | "export"; /** * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that * holds this policy's editable steps (its automation), output config and run @@ -160,6 +162,8 @@ export type PoliciesByCategory = Record; * backing folder (the real, working settings reused from the folder setup). */ export interface PolicyFolderSettings { + /** The editor event the policy runs on: "upload" or "export". */ + runOn: "upload" | "export"; outputMode: "new_file" | "new_version"; outputName: string; outputNamePosition: "prefix" | "suffix" | "auto-number"; From 9e5fe2f4ca6c1bc1c7766a0826f494a792828088 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:28:58 +0100 Subject: [PATCH 42/80] fix(payg): attribute policy runs to the owner so usage is charged (#6620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A policy ran over a file but the owner's **free usage was never consumed**. A policy run executes on a **background virtual thread** (`PolicyEngine.submit` → `asyncExecutor`), and Spring's `SecurityContextHolder` is thread-local — so the worker thread has no identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the tool-call API key via `UserService.getCurrentUsername()`, it finds nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback tool calls then authenticate as that system account, so `PaygChargeInterceptor` attributes the charge to *its* team (or none) — the real owner's free grant is untouched. Folder-watch / scheduled triggers are even further removed (fired from a background watch loop with no request context at all). The charging *mechanism* was fine (AUTOMATION, multipart, `openProcess`); only the **attribution** was wrong. ## Fix Propagate the acting identity onto the worker thread using the **audit-principal MDC key** that `UserService.getCurrentUsername()` already reads as its documented async fallback (the same mechanism used for other async jobs). No new plumbing through the executor. - **`runPolicy`** (stored policies — covers triggers *and* manual `runWith`) → bill the **policy owner**. `Policy.owner` is the username stamped at creation, so `getApiKeyForUser(owner)` resolves it. - **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting user**, captured on the request thread (it doesn't survive the hop to the worker otherwise). With the principal set, `InternalApiClient` dispatches each tool call as that user → the interceptor resolves the right team → free grant draws / Stripe meters correctly. ## Tests `PolicyEngineTest`: - `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC `auditPrincipal` == the policy owner at the moment `InternalApiClient.post` is invoked. - `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the submitting user for an ad-hoc run. `:proprietary:test` + `:saas:test` + spotless green; coverage gates met. ## Heads-up (not in this PR) Once attributed, **automatic folder-watch / scheduled runs consume free grant (or bill) per file** — set up once, runs forever. That's automation-is-billable working as intended, but a set-and-forget policy can drain an allowance fast, so it may warrant a per-policy cap or a heads-up in the UI. Flagging for a product decision. --- .../policy/engine/PolicyEngine.java | 87 +++++++++++++++++-- .../policy/engine/PolicyEngineTest.java | 81 +++++++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 26a291589..1118d0058 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -8,8 +8,11 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; +import org.slf4j.MDC; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @@ -71,6 +74,25 @@ public class PolicyEngine { */ public PolicyRunHandle submit( PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) { + // Ad-hoc run (no stored policy): bill whoever kicked it off. Capture the principal on this + // (request) thread — it does not survive the hop onto the async worker. + return submitForPrincipal(currentActingPrincipal(), definition, inputs, listener); + } + + /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ + public PolicyRunHandle runPolicy( + Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { + // Bill the policy owner. Trigger-fired runs have no security context at all, and even an + // on-demand run executes on a background worker that doesn't inherit the caller's context — + // so the owner (a username stamped at policy creation) is the reliable billing identity. + return submitForPrincipal(policy.owner(), policy.toDefinition(), inputs, listener); + } + + private PolicyRunHandle submitForPrincipal( + String actingPrincipal, + PipelineDefinition definition, + PolicyInputs inputs, + PolicyProgressListener listener) { // Scope the run id to the current user (this request thread) so the file-download // ownership check passes. No-op when security is off. String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString()); @@ -79,7 +101,16 @@ public class PolicyEngine { registry.register(run); CompletableFuture completion = new CompletableFuture<>(); PolicyProgressListener tracking = trackingListener(runId, run, listener); - Runnable task = () -> runToCompletion(run, inputs, tracking, completion); + // Re-establish the acting principal as the audit principal on the worker thread. Each tool + // step dispatches via InternalApiClient, which resolves the caller from + // UserService.getCurrentUsername() — that has an MDC `auditPrincipal` fallback for async + // threads. Without this the worker has no identity, tool calls fall back to the + // INTERNAL_API_USER, and PAYG charges that system account instead of the owner's team. + Runnable task = + () -> + runAsPrincipal( + actingPrincipal, + () -> runToCompletion(run, inputs, tracking, completion)); // One admission unit per run; steps run synchronously within it, so this gates heavy work // without the pool-within-pool risk of queueing each tool call. @@ -100,12 +131,6 @@ public class PolicyEngine { return new PolicyRunHandle(runId, completion); } - /** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */ - public PolicyRunHandle runPolicy( - Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { - return submit(policy.toDefinition(), inputs, listener); - } - public PolicyRun getRun(String runId) { return registry.get(runId); } @@ -241,4 +266,52 @@ public class PolicyEngine { "The %s tool did not respond within %d seconds and was aborted.", e.getEndpointPath(), e.getReadTimeout().toSeconds()); } + + /** + * MDC key {@code UserService.getCurrentUsername()} reads as its async fallback (stamped by the + * controller audit aspect on request threads). We reuse it to carry the billing identity onto + * the policy worker thread. + */ + private static final String AUDIT_PRINCIPAL_MDC_KEY = "auditPrincipal"; + + /** + * The username to bill an ad-hoc run to, captured on the submitting (request) thread. Prefers + * the audit principal the controller aspect already stamped; falls back to the security context + * name. {@code anonymousUser} (and no identity) resolve to null so we don't try to bill it. + */ + private static String currentActingPrincipal() { + String mdc = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); + if (mdc != null && !mdc.isBlank()) { + return mdc; + } + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + String name = auth.getName(); + return "anonymousUser".equals(name) ? null : name; + } + + /** + * Run {@code body} with {@code principal} set as the audit principal in MDC, so async tool + * dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is. + * Restores the previous MDC value afterward (defensive — worker threads aren't pooled). + */ + private static void runAsPrincipal(String principal, Runnable body) { + if (principal == null || principal.isBlank()) { + body.run(); + return; + } + String previous = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); + MDC.put(AUDIT_PRINCIPAL_MDC_KEY, principal); + try { + body.run(); + } finally { + if (previous != null) { + MDC.put(AUDIT_PRINCIPAL_MDC_KEY, previous); + } else { + MDC.remove(AUDIT_PRINCIPAL_MDC_KEY); + } + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java index e47916dbe..af9d7a427 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyEngineTest.java @@ -28,6 +28,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; @@ -204,6 +205,86 @@ class PolicyEngineTest { verify(internalApiClient).post(eq(ROTATE), any()); } + @Test + void runPolicyDispatchesToolCallsAsTheOwner() throws Exception { + // Billing-attribution regression: the pipeline runs on a background worker thread, but the + // policy owner must be propagated as the audit principal so InternalApiClient (and thus + // PAYG) attributes each tool call to the owner — not the INTERNAL_API_USER fallback. + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); + int[] counter = {0}; + when(fileStorage.storeInputStream(any(InputStream.class), anyString())) + .thenAnswer( + inv -> + new StoredFile( + "file-" + ++counter[0], + ((InputStream) inv.getArgument(0)).readAllBytes().length)); + + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + inv -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + return ResponseEntity.ok(pdf("rotated", "rotated.pdf")); + }); + + Policy policy = + new Policy( + "p1", + "rotate", + "alice", + true, + null, + List.of(new PipelineStep(ROTATE, Map.of())), + OutputSpec.inline()); + + engine.runPolicy( + policy, + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + + assertEquals("alice", principalAtDispatch[0]); + } + + @Test + void adHocRunDispatchesToolCallsAsTheSubmittingUser() throws Exception { + // Ad-hoc runs (no stored policy) bill whoever kicked them off; the principal is captured on + // the request thread (here simulated via MDC) and re-established on the worker thread. + when(toolMetadataService.isMultiInput(anyString())).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false); + int[] counter = {0}; + when(fileStorage.storeInputStream(any(InputStream.class), anyString())) + .thenAnswer( + inv -> + new StoredFile( + "file-" + ++counter[0], + ((InputStream) inv.getArgument(0)).readAllBytes().length)); + + String[] principalAtDispatch = {""}; + when(internalApiClient.post(eq(ROTATE), any())) + .thenAnswer( + inv -> { + principalAtDispatch[0] = MDC.get("auditPrincipal"); + return ResponseEntity.ok(pdf("rotated", "rotated.pdf")); + }); + + MDC.put("auditPrincipal", "bob"); // the request thread's audit principal + try { + engine.submit( + definition(new PipelineStep(ROTATE, Map.of())), + PolicyInputs.of(List.of(pdf("input", "input.pdf"))), + PolicyProgressListener.NOOP) + .completion() + .get(10, TimeUnit.SECONDS); + } finally { + MDC.remove("auditPrincipal"); + } + + assertEquals("bob", principalAtDispatch[0]); + } + @Test void runIsQueuedUnderResourcePressure() { when(resourceMonitor.shouldQueueJob(anyInt())).thenReturn(true); From 1d598d5caa9e937dbba24289da58abad95c4276e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:30:49 +0100 Subject: [PATCH 43/80] MCP OAuth discovery fix + Supabase consent page (#6608) --- .../common/model/ApplicationProperties.java | 9 + .../src/main/resources/settings.yml.template | 3 + .../mcp/security/McpAudienceValidator.java | 35 +- .../mcp/security/McpSecurityConfig.java | 19 +- .../security/McpAudienceValidatorTest.java | 24 + .../mcp/security/McpOAuthIntegrationTest.java | 21 +- .../public/locales/en-GB/translation.toml | 517 ++++++++++-------- frontend/editor/src/core/constants/routes.ts | 1 + .../config/configSections/AdminMcpSection.tsx | 28 + frontend/editor/src/saas/App.tsx | 28 +- frontend/editor/src/saas/routes/Login.tsx | 34 +- .../editor/src/saas/routes/OAuthConsent.tsx | 410 ++++++++++++++ .../editor/src/saas/services/apiClient.ts | 15 +- frontend/editor/src/saas/utils/pathUtils.ts | 8 +- .../docker-compose-saas-mcp.override.yml | 34 ++ testing/compose/validate-mcp-saas-chain.sh | 94 ++++ testing/compose/validate-mcp-test.sh | 24 + testing/compose/walk-mcp-discovery.sh | 112 ++++ 18 files changed, 1146 insertions(+), 270 deletions(-) create mode 100644 frontend/editor/src/saas/routes/OAuthConsent.tsx create mode 100644 testing/compose/docker-compose-saas-mcp.override.yml create mode 100755 testing/compose/validate-mcp-saas-chain.sh create mode 100755 testing/compose/walk-mcp-discovery.sh diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 067be54eb..31d3fc4e6 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -367,6 +367,15 @@ public class ApplicationProperties { */ private String resourceId = ""; + /** + * Additional JWT audiences accepted at the MCP endpoint, on top of {@link #resourceId}. + * Empty (default) keeps strict RFC 8707 binding. Some IdPs cannot mint + * resource-specific audiences - e.g. Supabase's OAuth server always issues {@code + * aud=authenticated} - so operators list the audience their IdP actually emits here + * (env: {@code MCP_AUTH_ACCEPTEDAUDIENCES}, comma-separated). + */ + private List acceptedAudiences = new ArrayList<>(); + /** * JWT claim whose value is matched against a provisioned Stirling username. Defaults to * {@code sub}; set to {@code email} or {@code preferred_username} to match how your IdP diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 413f39706..957a0a51a 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -390,6 +390,9 @@ mcp: jwksUri: "" # JWKS URI. Blank -> derived from issuer's /.well-known/openid-configuration. resourceId: "" # RFC 8707 resource identifier of THIS MCP server (e.g. http://localhost:8080/mcp). # Required: tokens must list this id in `aud` or the request is rejected. + acceptedAudiences: [] # Extra `aud` values accepted on top of resourceId. Empty = strict RFC 8707. + # For IdPs that cannot mint resource audiences (Supabase OAuth server always + # issues aud=authenticated) list that audience here, e.g. ['authenticated']. usernameClaim: sub # JWT claim matched against a Stirling username (e.g. 'sub', 'email', 'preferred_username') requireExistingAccount: true # Reject tokens whose subject has no enabled Stirling account (recommended) engineCapabilityRefreshMinutes: 5 # How often to refresh the AI capabilities manifest from the engine diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java index 7430776de..9603cc780 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpAudienceValidator.java @@ -1,6 +1,9 @@ package stirling.software.proprietary.mcp.security; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; @@ -8,20 +11,35 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; /** - * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id in its - * {@code aud} claim. Fails closed when the resource id is unset. + * RFC 8707 audience binding: a JWT at the MCP endpoint must list this server's resource id (or one + * of the explicitly accepted additional audiences) in its {@code aud} claim. The additional list + * exists for IdPs that cannot mint resource-specific audiences - e.g. Supabase's OAuth server + * always issues {@code aud=authenticated}. Fails closed when nothing is configured. */ public class McpAudienceValidator implements OAuth2TokenValidator { - private final String expectedResourceId; + private final Set acceptedAudiences; public McpAudienceValidator(String expectedResourceId) { - this.expectedResourceId = expectedResourceId == null ? "" : expectedResourceId; + this(expectedResourceId, List.of()); + } + + public McpAudienceValidator(String expectedResourceId, Collection additionalAudiences) { + Set accepted = new LinkedHashSet<>(); + if (expectedResourceId != null && !expectedResourceId.isBlank()) { + accepted.add(expectedResourceId); + } + if (additionalAudiences != null) { + additionalAudiences.stream() + .filter(a -> a != null && !a.isBlank()) + .forEach(accepted::add); + } + this.acceptedAudiences = accepted; } @Override public OAuth2TokenValidatorResult validate(Jwt token) { - if (expectedResourceId.isBlank()) { + if (acceptedAudiences.isEmpty()) { return OAuth2TokenValidatorResult.failure( new OAuth2Error( "invalid_token", @@ -30,12 +48,13 @@ public class McpAudienceValidator implements OAuth2TokenValidator { null)); } List aud = token.getAudience(); - if (aud == null || !aud.contains(expectedResourceId)) { + if (aud == null || aud.stream().noneMatch(acceptedAudiences::contains)) { return OAuth2TokenValidatorResult.failure( new OAuth2Error( "invalid_token", - "Token audience does not include this server's resource id (" - + expectedResourceId + "Token audience does not include this server's resource id or an" + + " accepted audience (" + + String.join(", ", acceptedAudiences) + ").", null)); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java index ab6341ed3..199370105 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/mcp/security/McpSecurityConfig.java @@ -157,7 +157,11 @@ public class McpSecurityConfig { throws Exception { String metadataPath = "/.well-known/oauth-protected-resource"; applyCors(http); - http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath) + // RFC 9728 section 3.1: clients derive the metadata URL by inserting the well-known + // segment before the resource path, so /mcp is discovered at {metadataPath}/mcp. Claim + // the subpaths too; otherwise they fall through to another filter chain whose default + // Spring Security metadata filter serves a document without authorization_servers. + http.securityMatcher(BASE_PATH, BASE_PATH + "/**", metadataPath, metadataPath + "/**") // CSRF intentionally disabled: /mcp is a stateless JSON-RPC resource server // authenticated by OAuth2 Bearer JWTs (Authorization header). No cookies, no // session, no form submissions; CSRF requires browser-attached ambient credentials @@ -168,7 +172,8 @@ public class McpSecurityConfig { .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests( a -> - a.requestMatchers(HttpMethod.GET, metadataPath) + a.requestMatchers( + HttpMethod.GET, metadataPath, metadataPath + "/**") .permitAll() .anyRequest() .authenticated()) @@ -187,7 +192,11 @@ public class McpSecurityConfig { .oauth2ResourceServer( oauth2 -> oauth2.authenticationEntryPoint( - new McpAuthenticationEntryPoint(metadataPath)) + // Advertise the path-inserted form; RFC 9728 makes + // it the canonical location for a resource with a + // path component. + new McpAuthenticationEntryPoint( + metadataPath + BASE_PATH)) // RFC 9728 protected-resource metadata for OAuth discovery. .protectedResourceMetadata( prm -> @@ -236,7 +245,9 @@ public class McpSecurityConfig { JwtValidators.createDefaultWithIssuer(auth.getIssuerUri()); OAuth2TokenValidator combined = new DelegatingOAuth2TokenValidator<>( - defaultValidators, new McpAudienceValidator(auth.getResourceId())); + defaultValidators, + new McpAudienceValidator( + auth.getResourceId(), auth.getAcceptedAudiences())); decoder.setJwtValidator(combined); return decoder; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java index 06f23e1ff..5bee580b7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpAudienceValidatorTest.java @@ -53,6 +53,30 @@ class McpAudienceValidatorTest { assertThat(result.hasErrors()).isTrue(); } + @Test + void acceptedAudience_isAccepted_alongsideResourceId() { + // Supabase-style IdP: every token carries aud=authenticated, never the resource id. + McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("authenticated")); + assertThat(relaxed.validate(tokenWithAudience(List.of("authenticated"))).hasErrors()) + .isFalse(); + assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse(); + assertThat(relaxed.validate(tokenWithAudience(List.of("something-else"))).hasErrors()) + .isTrue(); + } + + @Test + void blankAcceptedAudienceEntries_areIgnored() { + McpAudienceValidator relaxed = new McpAudienceValidator(RESOURCE, List.of("", " ")); + assertThat(relaxed.validate(tokenWithAudience(List.of(""))).hasErrors()).isTrue(); + assertThat(relaxed.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isFalse(); + } + + @Test + void blankResourceIdWithOnlyBlankAccepted_failsClosed() { + McpAudienceValidator blank = new McpAudienceValidator("", List.of(" ")); + assertThat(blank.validate(tokenWithAudience(List.of(RESOURCE))).hasErrors()).isTrue(); + } + private static Jwt tokenWithAudience(List audience) { return new Jwt( "header.payload.signature", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java index 53f0b2660..1e814ea58 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpOAuthIntegrationTest.java @@ -116,7 +116,8 @@ class McpOAuthIntegrationTest { assertThat(response.statusCode()).isEqualTo(401); String wwwAuth = response.headers().firstValue("WWW-Authenticate").orElse(""); assertThat(wwwAuth).contains("resource_metadata="); - assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource"); + // The advertised URL must be the RFC 9728 path-inserted form for the /mcp resource. + assertThat(wwwAuth).contains("/.well-known/oauth-protected-resource/mcp"); } @Test @@ -247,6 +248,24 @@ class McpOAuthIntegrationTest { assertThat(response.body()).contains("mcp.tools.read"); } + @Test + void pathInsertedMetadataEndpoint_servesCustomizedMetadata() throws Exception { + // RFC 9728 path-inserted form for the /mcp resource. Must be served by the MCP chain + // with authorization_servers populated; a default/uncustomized document here makes MCP + // clients fall back to treating this server as its own authorization server. + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(base() + "/.well-known/oauth-protected-resource/mcp")) + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).contains(RESOURCE_ID); + assertThat(response.body()).contains("authorization_servers"); + assertThat(response.body()).contains(ISSUER); + assertThat(response.body()).contains("mcp.tools.read"); + } + private HttpResponse postMcp(String token, String body) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder() diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index bcf7b4668..214c05f16 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -275,7 +275,6 @@ saved = "Saved" text = "Text" [addPageNumbers] -tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool" customize = "Customize Appearance" customNumberDesc = "e.g., \"Page {n}\" or leave blank for just numbers" fontName = "Font Name" @@ -286,6 +285,7 @@ positionAndPages = "Position & Pages" preview = "Position Selection" previewDisclaimer = "Preview is approximate. Final output may vary due to PDF font metrics." submit = "Add Page Numbers" +tags = "number,pagination,count,add page numbers,page numbering,page numbers,footer,header,number pages,sequential,pagination tool" title = "Add Page Numbers" zeroPad = "Zero‑pad Width (Bates Stamping)" zeroPadTooltip = "Zero‑pad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable." @@ -303,9 +303,9 @@ title = "Page Number Results" 6 = "Custom Text Format" [addPassword] -tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access" desc = "Encrypt your PDF document with a password." submit = "Encrypt" +tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access" title = "Add Password" [addPassword.encryption.keyLength] @@ -351,6 +351,9 @@ title = "Password Types" text = "These permissions control what users can do with the PDF. Most effective when combined with an owner password." title = "Change Permissions" +[addStamp] +tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" + [AddStampRequest] alphabet = "Alphabet" clickToExpand = "Click to expand" @@ -421,6 +424,9 @@ header = "Add text to PDFs" tags = "text,annotation,label" title = "Add Text" +[addText.canvas] +colorPickerTitle = "Choose stroke colour" + [addText.error] failed = "An error occurred while adding text to the PDF." @@ -440,25 +446,6 @@ resume = "Resume placement" [addText.results] title = "Add Text Results" -[addText.step] -createDesc = "Enter the text you want to add" -place = "Place text" -placeDesc = "Click on the PDF to add your text" - -[addText.steps] -configure = "Configure Text" - -[addText.text] -colorLabel = "Text colour" -fontLabel = "Font" -fontSizeLabel = "Font size" -fontSizePlaceholder = "Type or select font size (8-200)" -name = "Text content" -placeholder = "Enter the text you want to add" - -[addText.canvas] -colorPickerTitle = "Choose stroke colour" - [addText.saved] defaultCanvasLabel = "Drawing signature" defaultImageLabel = "Uploaded signature" @@ -481,6 +468,22 @@ canvas = "Drawing" image = "Upload" text = "Text" +[addText.step] +createDesc = "Enter the text you want to add" +place = "Place text" +placeDesc = "Click on the PDF to add your text" + +[addText.steps] +configure = "Configure Text" + +[addText.text] +colorLabel = "Text colour" +fontLabel = "Font" +fontSizeLabel = "Font size" +fontSizePlaceholder = "Type or select font size (8-200)" +name = "Text content" +placeholder = "Enter the text you want to add" + [addText.type] canvas = "Canvas" image = "Image" @@ -491,7 +494,6 @@ text = "Text" tags = "color-correction,tune,modify,enhance,colour-correction" [adjustContrast] -tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance" adjustColors = "Adjust Colors" basic = "Basic Adjustments" blue = "Blue" @@ -502,6 +504,7 @@ green = "Green" noPreview = "Select a PDF to preview" red = "Red" saturation = "Saturation:" +tags = "contrast,brightness,saturation,adjust colors,color correction,enhance,lighten,darken,improve quality,color balance,hue,vibrance" title = "Adjust Colors/Contrast" [adjustContrast.error] @@ -1131,6 +1134,10 @@ apikeyNote = "Clients send a Stirling API key in the X-API-KEY header (or Author description = "Expose Stirling's PDF tools and AI agents to MCP clients over an OAuth-protected endpoint." title = "MCP Server" +[admin.settings.mcp.acceptedAudiences] +description = "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated." +label = "Additional accepted audiences (optional)" + [admin.settings.mcp.allowedOps] description = "If set, ONLY these operation ids are exposed (an allow-list; comma or space separated). Leave blank to expose all enabled tools except any blocked below." label = "Allowed tools" @@ -1603,6 +1610,9 @@ paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." title = "Do you want to help make Stirling PDF better?" +[annotate] +tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" + [annotation] annotationStyle = "Annotation style" backgroundColor = "Background colour" @@ -1832,7 +1842,6 @@ bullet3 = "Keeps the original name if no suitable title is found" title = "Smart Renaming" [automate] -tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" copyToSaved = "Copy to Saved" desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." export = "Export" @@ -1842,6 +1851,7 @@ importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}" importSuccess = "Imported automation: {{name}}" invalidStep = "Invalid step" reviewTitle = "Automation Results" +tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations" title = "Automate" [automate.config] @@ -1939,8 +1949,8 @@ secureWorkflow = "Security Workflow" secureWorkflowDesc = "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default." [autoRename] -tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" description = "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text." +tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title" [autoSizeSplitPDF] tags = "pdf,split,document,organization" @@ -2520,9 +2530,9 @@ ssoManaged = "Your account is managed by your identity provider." title = "Change Credentials" [changeMetadata] -tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties" filenamePrefix = "metadata" submit = "Change" +tags = "edit,modify,update,metadata,properties,document properties,author,title,subject,keywords,creator,producer,info,document info,file properties" [changeMetadata.advanced] title = "Advanced Options" @@ -2628,9 +2638,9 @@ true = "True" unknown = "Unknown" [changePermissions] -tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" desc = "Change document restrictions and permissions." submit = "Change Permissions" +tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" title = "Change Permissions" [changePermissions.error] @@ -2678,6 +2688,10 @@ resize = "Resize chat panel" [chat.actions] copy = "Copy message" +[chat.fab] +close = "Close chat" +open = "Open Stirling AI assistant" + [chat.header] agentMenu = "Stirling agent options" clearChat = "Clear chat" @@ -2695,18 +2709,18 @@ executing_tool_single = "Running {{tool}}..." executing_tool_step = "Running {{tool}} (step {{step}} of {{total}})..." extracting_content = "Extracting content from your documents..." processing = "Processing extracted content..." +ranForMinutes = "Ran for {{count}} minutes" +ranForMinutes_one = "Ran for 1 minute" +ranForMinutes_other = "Ran for {{count}} minutes" +ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec" +ranForSeconds = "Ran for {{count}} seconds" +ranForSeconds_one = "Ran for 1 second" +ranForSeconds_other = "Ran for {{count}} seconds" thinking = "Thinking..." whole_doc_compression_round = "Consolidating notes..." whole_doc_read_done = "Finished reading the document..." whole_doc_read_started = "Reading the document..." whole_doc_slice_done = "Reading the document... ({{percent}}% complete)" -ranForSeconds = "Ran for {{count}} seconds" -ranForSeconds_one = "Ran for 1 second" -ranForSeconds_other = "Ran for {{count}} seconds" -ranForMinutes = "Ran for {{count}} minutes" -ranForMinutes_one = "Ran for 1 minute" -ranForMinutes_other = "Ran for {{count}} minutes" -ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec" [chat.quickActions] browseYourFiles = "Browse your files" @@ -2736,10 +2750,6 @@ unsupported_capability = "Unsupported capability: {{capability}}" summary = "Ran {{count}} tools" unknownTool = "Unknown tool" -[chat.fab] -close = "Close chat" -open = "Open Stirling AI assistant" - [cloudBadge] tooltip = "This operation will use your cloud credits" @@ -2895,9 +2905,9 @@ unlinkedTitle = "Independent scroll & pan enabled" message = "These documents appear highly dissimilar. Comparison was stopped to save time." [compress] -tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size" desc = "Compress PDFs to reduce their file size." submit = "Compress" +tags = "shrink,reduce,optimize,compress,smaller,downsize,file size,reduce size,minimize,make smaller,decrease size,optimize size" title = "Compress" [compress.compressionLevel] @@ -3102,7 +3112,6 @@ selfhostedOffline = "Self-hosted server unreachable" selfhostedOnline = "Connected to self-hosted server" [convert] -tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" blackwhite = "Black & White" @@ -3162,6 +3171,7 @@ strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification) svgPdfOptions = "SVG to PDF Options" svgToPdf = "SVG → PDF" svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size." +tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as" targetFormatPlaceholder = "Target format" title = "Convert" webOptions = "Web to PDF Options" @@ -3381,6 +3391,18 @@ docsLink = "View installation documentation" message = "Stirling-PDF does not have permission to update itself on this machine." title = "Administrator permissions required" +[devAirgapped] +tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone" + +[devApi] +tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting" + +[devFolderScanning] +tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring" + +[devSsoGuide] +tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP" + [dropdownList] searchPlaceholder = "Search..." @@ -3389,8 +3411,8 @@ edit = "Edit" editSecretValue = "Edit secret value" [editTableOfContents] -tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline" submit = "Apply table of contents" +tags = "bookmarks,contents,edit,table of contents,TOC,outline,navigation,chapters,sections,add bookmarks,edit bookmarks,PDF outline" [editTableOfContents.actions] clipboardUnavailable = "Clipboard access is not available in this browser." @@ -3524,8 +3546,8 @@ title = "Settings" tags = "extract" [extractPages] -tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages" submit = "Extract Pages" +tags = "pull,select,copy,extract,extract pages,get pages,pull out,save pages,export pages,copy pages,select pages,specific pages" title = "Extract Pages" [extractPages.error] @@ -3923,8 +3945,8 @@ welcomeMessage = "For security reasons, you must change your password on your fi welcomeTitle = "Welcome!" [flatten] -tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable" submit = "Flatten" +tags = "simplify,remove,interactive,flatten,flatten form,remove form fields,make static,finalize form,lock form,disable editing,convert to image,non-editable" title = "Flatten" [flatten.error] @@ -4569,6 +4591,7 @@ signIn = "Sign In" accountCreatedSuccess = "Account created successfully! You can now sign in." backToSignIn = "Back to sign in" changePasswordWarning = "Please change your password after logging in for the first time" +createAccount = "Create an account" credentialsUpdated = "Your credentials have been updated. Please sign in again." defaultCredentials = "Default Login Credentials" email = "Email" @@ -4598,7 +4621,6 @@ sending = "Sending…" sendMagicLink = "Send Magic Link" sendResetLink = "Send reset link" sessionExpired = "Your session has expired. Please sign in again." -createAccount = "Create an account" signin = "Sign in" signingIn = "Signing in..." signInWith = "Sign in with" @@ -4736,6 +4758,34 @@ title = "Authentication Failed" message = "You can close this window and return to Stirling PDF." title = "Authentication Successful" +[oauthConsent] +approve = "Allow access" +approving = "Allowing..." +decisionFailed = "Could not submit your decision. Please try again." +deny = "Deny" +denying = "Denying..." +loadFailed = "This authorization request is invalid or has expired. Close this tab and try connecting again from the app." +loading = "Loading authorization request..." +missingId = "Missing authorization request. Start the connection from your app and try again." +redirecting = "Returning you to the app..." +requesting = "{{app}} wants to access your Stirling PDF account" +scopesIntro = "This will allow {{app}} to:" +signedInAs = "Signed in as {{name}}" +signInButton = "Sign in to continue" +signInPrompt = "Sign in to your Stirling PDF account to continue connecting the app." +title = "Authorize access" +unknownApp = "A third-party application" + +[oauthConsent.access] +actAsYou = "Act as you - everything {{app}} does runs under your account and counts towards your usage" +tools = "Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents" + +[oauthConsent.scope] +email = "See your email address" +openid = "Confirm your identity" +phone = "See your phone number" +profile = "See your basic profile information" + [ocr] desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text." tags = "recognition,text,image,scan,read,identify,detection,editable" @@ -5477,7 +5527,6 @@ REVERSE_ORDER = "Flip the document so the last page becomes first and so on." SIDE_STITCH_BOOKLET_SORT = "Arrange pages for side‑stitch booklet printing (optimized for binding on the side)." [pdfTextEditor] -tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor" conversionFailed = "Failed to convert PDF. Please try again." converting = "Converting PDF to editable format..." currentFile = "Current file: {{name}}" @@ -5485,6 +5534,7 @@ imageLabel = "Placed image" noTextOnPage = "No editable text was detected on this page." pagePreviewAlt = "Page preview" pageSummary = "Page {{number}} of {{total}}" +tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor" title = "PDF Text Editor" viewLabel = "PDF Editor" @@ -5646,9 +5696,9 @@ title = "PDF to Presentation" tags = "single page" [pdfToSinglePage] -tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster" description = "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights." submit = "Convert To Single Page" +tags = "combine,merge,single,single page,one page,merge to single,combine all,stitch pages,concatenate vertical,long page,poster" title = "PDF To Single Page" [pdfToSinglePage.error] @@ -5844,6 +5894,10 @@ subscribeToPro = "Subscribe to Pro" subscriptionScheduled = "Subscription scheduled - starts {{date}}" title = "Free Trial Active" +[policies] +deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." +deleteConfirmTitle = "Delete policy?" + [printFile] title = "Print File" @@ -6124,6 +6178,9 @@ whatsNewTourDesc = "Tour the updated layout" admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." +[read] +tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" + [redact] submit = "Redact" tags = "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact" @@ -6254,8 +6311,8 @@ title = "What it does" title = "About Remove Annotations" [removeBlanks] -tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank" submit = "Remove blank pages" +tags = "delete,clean,empty,remove blank,delete blank pages,empty pages,white pages,remove empty,clean up,cleanup blank" title = "Remove Blanks" [removeBlanks.error] @@ -6313,8 +6370,8 @@ failed = "An error occurred whilst removing certificate signatures." title = "Certificate Removal Results" [removeImage] -tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics" submit = "Remove image" +tags = "remove,delete,clean,remove image,delete image,strip images,remove pictures,delete photos,clean images,reduce size,remove graphics" title = "Remove image" [removeImage.error] @@ -6385,8 +6442,8 @@ title = "Decrypted PDFs" description = "Removing password protection requires the password that was used to encrypt the PDF. This will decrypt the document, making it accessible without a password." [reorganizePages] -tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" submit = "Reorganize Pages" +tags = "rearrange,reorder,organize,reorganize,move pages,page order,sort pages,arrange pages,shuffle,resequence" [reorganizePages.error] failed = "Failed to reorganize pages" @@ -6469,11 +6526,11 @@ text = "Completely invert all colours in the PDF, creating a negative-like effec title = "Invert All Colours" [rotate] -tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation" rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" selectRotation = "Select Rotation Angle (Clockwise)" submit = "Apply Rotation" +tags = "turn,flip,orient,rotate,orientation,landscape,portrait,90 degrees,180 degrees,clockwise,anticlockwise,counter-clockwise,fix orientation" title = "Rotate PDF" [rotate.error] @@ -6493,10 +6550,10 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments title = "Rotate Settings Overview" [sanitize] -tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" desc = "Remove potentially harmful elements from PDF files." sanitizationResults = "Sanitisation Results" submit = "Sanitise PDF" +tags = "clean,purge,remove,sanitize,sanitise,remove scripts,remove javascript,remove metadata,strip metadata,security,clean document,remove hidden data,privacy" title = "Sanitise" [sanitize.error] @@ -6543,6 +6600,9 @@ title = "Sanitise PDF" tags = "resize,adjust,scale,page size,resize page,scale page,change size,adjust size,enlarge,shrink page,fit to page,A4,letter size" title = "Adjust page-scale" +[scannerEffect] +tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" + [ScannerImageSplit.selectText] 1 = "Angle Threshold:" 10 = "Extra padding (in pixels) around each saved photo so edges aren't cut." @@ -6967,7 +7027,6 @@ title = "Show Javascript" title = "Extracted JavaScript" [sign] -tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" activate = "Activate Signature Placement" add = "Add" addToAll = "Add to all pages" @@ -6987,6 +7046,7 @@ redo = "Redo" save = "Save Signature" sharedSigs = "Shared Signatures" submit = "Sign Document" +tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting" title = "Sign" undo = "Undo" updateAndPlace = "Update and Place" @@ -7174,11 +7234,11 @@ small = "Small" x-large = "X-Large" [split] -tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter" resultsTitle = "Split Results" selectMethod = "Select a split method" splitPages = "Enter pages to split on:" submit = "Split" +tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter" title = "Split PDF" [split.error] @@ -7538,10 +7598,10 @@ justNow = "just now" minutesAgo = "{{count}}m ago" [timestampPdf] -tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise" desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server." results = "Timestamp Results" submit = "Apply Timestamp" +tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise" title = "Timestamp PDF" [timestampPdf.error] @@ -7937,10 +7997,173 @@ title = "View/Edit PDF" [warning] tooltipTitle = "Warning" +[watchedFolders] +alreadyInFolder = "Already in this folder" +defaultFolderWarning = "This is a default folder and will be recreated on next reload." +deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." +deleteConfirmTitle = "Delete folder?" +folderNotFound = "Folder not found" +newFolder = "New folder" +noFolders = "No watched folders yet" +sidebarTitle = "Watched Folders" +title = "Watched Folders" + +[watchedFolders.actions] +back = "Back" +collapse = "Collapse" +dismiss = "Dismiss" +download = "Download" +downloadInput = "Download input" +downloadOutput = "Download output" +expand = "Expand" +retry = "Retry" + +[watchedFolders.card] +delete = "Delete folder" +edit = "Edit folder" + +[watchedFolders.home] +addAnother = "Add another Watched Folder" +addAnotherDesc = "Automatically process files with a new pipeline" +create = "Create your first Watched Folder" +deleteFolder = "Delete folder" +editFolder = "Edit folder" +empty = "No watched folders yet" +emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." +emptyTitle = "Automate your PDF workflows" +file = "file" +files = "files" +pause = "Pause" +resume = "Resume" + +[watchedFolders.howItWorks] +step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" +step1Title = "Drop files" +step2Desc = "Your configured tools process each file automatically" +step2Title = "Pipeline runs" +step3Desc = "Download processed files from inside the folder" +step3Title = "Output ready" +title = "How Watched Folders work" + +[watchedFolders.modal] +advanced = "Advanced" +automation = "Automation" +automationNameFallback = "Watched Folder automation" +automationRequired = "Add at least one configured step before saving." +autoNumber = "Auto-number" +autoNumberExample = "e.g. document.pdf → document (1).pdf" +autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." +changeFolder = "Change" +chooseFolder = "Choose" +clearFolder = "Clear" +color = "Accent color" +createFolder = "Create Folder" +createTitle = "New Watched Folder" +editTitle = "Edit Watched Folder" +inputFolder = "Input folder" +inputFolderNotChosen = "No folder chosen — required for auto-scan" +inputSource = "Input source" +inputSourceBrowser = "Browser — drop files here" +inputSourceLocal = "Local folder (auto-scan)" +inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" +localOutputFolder = "Local output folder" +maxRetries = "Max retries" +name = "Folder name" +namePlaceholder = "My Watched Folder" +nameRequired = "Folder name is required" +nameTooLong = "Folder name must be 50 characters or less" +outputFolderNotSet = "Not set — outputs stay in app" +outputFolderUnsupported = "Not supported in this browser" +outputModeNewDesc = "Output is saved as a new separate file" +outputModeVersion = "Replace original?" +outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" +outputName = "Output filename prefix" +outputNamePrefix = "Filename prefix" +outputNameSuffix = "Suffix" +positionPrefix = "Prefix" +positionSuffix = "Suffix" +replaceOriginal = "Replace original file" +retryDelay = "Delay (minutes)" +saveChanges = "Save Changes" +saveFailed = "Failed to save folder. Please try again." +sectionFolder = "Folder" +sectionSourceOutput = "Source & Output" +sectionSteps = "Steps" + +[watchedFolders.status] +active = "Active" +done = "Done" +paused = "Paused" +processing = "Processing" + +[watchedFolders.time] +daysAgo = "{{count}}d ago" +hoursAgo = "{{count}}h ago" +justNow = "just now" +minutesAgo = "{{count}}m ago" + +[watchedFolders.workbench] +activity = "Activity" +addFiles = "Add files" +allTime = "All time" +cancel = "Cancel" +chartTooltipComplete = "{{count}} complete" +chartTooltipFailed = "{{count}} failed" +clearSelection = "Clear selection" +countProcessing = "{{count}} processing" +countQueued = "{{count}} queued" +countSelected = "{{count}} selected" +dataSaved = "Saved" +dayRunning = "day running" +daysRunning = "days running" +delete = "Delete" +deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." +deleteOutputs = "Delete outputs" +directionIn = "in" +directionOut = "out" +dropToProcess = "Drop to process" +export = "Export" +exportSeparately = "Export separately" +exportZip = "Export zip" +failed = "Failed" +filesProcessedOverTime = "Files processed over time" +filterAll = "All" +filterPending = "Pending" +inputs = "Inputs" +inputSize = "Input size" +legendComplete = "Complete" +noActivity = "No activity yet — drop a PDF to start" +noActivityMatch = "No matching activity" +noInputFolder = "No input folder — edit to configure" +outputs = "Outputs" +pause = "Pause" +preview = "Preview" +previewInput = "Preview input" +previewLoadFailed = "Could not load file preview." +previewOutput = "Preview output" +queued = "Queued" +queuedWaiting = "Queued — waiting to run" +removeEntries = "Remove {{count}} entries" +removeEntry = "Remove entry" +removeNotificationsOnly = "Remove notifications only" +resume = "Resume" +retryAll = "Retry all" +retryIn = "retry in {{count}}m" +retryingSoon = "retrying…" +retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" +running = "Running" +search = "Search…" +selectAll = "Select all {{count}}" +sortNameAsc = "A → Z" +sortNameDesc = "Z → A" +sortNewest = "Newest" +sortOldest = "Oldest" +watching = "Watching: {{name}}" + [watermark] -tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text" desc = "Add text or image watermarks to PDF files" submit = "Add Watermark" +tags = "stamp,mark,overlay,watermark,branding,logo,confidential,draft,copyright,trademark,text overlay,image overlay,background text" title = "Add Watermark" [watermark.error] @@ -8401,189 +8624,3 @@ cancel = "Cancel" confirm = "Extract" message = "This ZIP contains {{count}} files. Extract anyway?" title = "Large ZIP File" - -[watchedFolders] -alreadyInFolder = "Already in this folder" -deleteConfirmBody = "This will remove the folder and its run history. Files already downloaded are not affected." -deleteConfirmTitle = "Delete folder?" -defaultFolderWarning = "This is a default folder and will be recreated on next reload." -folderNotFound = "Folder not found" -newFolder = "New folder" -noFolders = "No watched folders yet" -sidebarTitle = "Watched Folders" -title = "Watched Folders" - -[watchedFolders.modal] -automation = "Automation" -createFolder = "Create Folder" -automationRequired = "Add at least one configured step before saving." -color = "Accent color" -createTitle = "New Watched Folder" -editTitle = "Edit Watched Folder" -name = "Folder name" -namePlaceholder = "My Watched Folder" -nameRequired = "Folder name is required" -nameTooLong = "Folder name must be 50 characters or less" -saveChanges = "Save Changes" -saveFailed = "Failed to save folder. Please try again." -automationNameFallback = "Watched Folder automation" -maxRetries = "Max retries" -retryDelay = "Delay (minutes)" -outputModeVersion = "Replace original?" -outputModeVersionDesc = "Output becomes a new version of the input file rather than a separate file" -outputModeNewDesc = "Output is saved as a new separate file" -outputName = "Output filename prefix" -outputNameSuffix = "Suffix" -sectionFolder = "Folder" -sectionSourceOutput = "Source & Output" -sectionSteps = "Steps" -inputSource = "Input source" -inputSourceBrowser = "Browser — drop files here" -inputSourceLocal = "Local folder (auto-scan)" -inputSourceLocalUnsupported = "Local folder (auto-scan) — Chrome/Edge only" -inputFolder = "Input folder" -inputFolderNotChosen = "No folder chosen — required for auto-scan" -changeFolder = "Change" -chooseFolder = "Choose" -clearFolder = "Clear" -autoScanHelp = "New PDF files in this folder are processed automatically every 10 seconds." -localOutputFolder = "Local output folder" -outputFolderUnsupported = "Not supported in this browser" -outputFolderNotSet = "Not set — outputs stay in app" -advanced = "Advanced" -replaceOriginal = "Replace original file" -autoNumber = "Auto-number" -autoNumberExample = "e.g. document.pdf → document (1).pdf" -outputNamePrefix = "Filename prefix" -positionPrefix = "Prefix" -positionSuffix = "Suffix" - -[watchedFolders.home] -create = "Create your first Watched Folder" -editFolder = "Edit folder" -empty = "No watched folders yet" -file = "file" -files = "files" -emptyTitle = "Automate your PDF workflows" -emptyDesc = "Set up a Watched Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." -addAnother = "Add another Watched Folder" -addAnotherDesc = "Automatically process files with a new pipeline" -resume = "Resume" -pause = "Pause" -deleteFolder = "Delete folder" - -[watchedFolders.card] -edit = "Edit folder" -delete = "Delete folder" - -[watchedFolders.status] -done = "Done" -processing = "Processing" -paused = "Paused" -active = "Active" - -[watchedFolders.howItWorks] -title = "How Watched Folders work" -step1Title = "Drop files" -step1Desc = "Drag PDFs onto any Watched Folder card — or send them from your file list" -step2Title = "Pipeline runs" -step2Desc = "Your configured tools process each file automatically" -step3Title = "Output ready" -step3Desc = "Download processed files from inside the folder" - -[watchedFolders.workbench] -addFiles = "Add files" -inputs = "Inputs" -outputs = "Outputs" -failed = "Failed" -dataSaved = "Saved" -running = "Running" -dropToProcess = "Drop to process" -activity = "Activity" -noActivity = "No activity yet — drop a PDF to start" -noActivityMatch = "No matching activity" -retryAll = "Retry all" -retryIn = "retry in {{count}}m" -retryingSoon = "retrying…" -pause = "Pause" -resume = "Resume" -dayRunning = "day running" -daysRunning = "days running" -sortNewest = "Newest" -sortOldest = "Oldest" -sortNameAsc = "A → Z" -sortNameDesc = "Z → A" -filterAll = "All" -filterPending = "Pending" -search = "Search…" -retryMixedConfirm = "Some selected files have already completed and will be skipped. Only failed files will be retried. Continue?" -watching = "Watching: {{name}}" -noInputFolder = "No input folder — edit to configure" -countProcessing = "{{count}} processing" -countQueued = "{{count}} queued" -exportZip = "Export zip" -exportSeparately = "Export separately" -countSelected = "{{count}} selected" -selectAll = "Select all {{count}}" -delete = "Delete" -clearSelection = "Clear selection" -preview = "Preview" -export = "Export" -previewInput = "Preview input" -previewOutput = "Preview output" -directionIn = "in" -directionOut = "out" -queuedWaiting = "Queued — waiting to run" -allTime = "All time" -queued = "Queued" -inputSize = "Input size" -filesProcessedOverTime = "Files processed over time" -legendComplete = "Complete" -chartTooltipComplete = "{{count}} complete" -chartTooltipFailed = "{{count}} failed" -removeEntry = "Remove entry" -removeEntries = "Remove {{count}} entries" -deleteConfirmBody = "Remove notifications only clears the activity log. Delete outputs also removes the processed files from storage. Your original input files are never touched." -cancel = "Cancel" -removeNotificationsOnly = "Remove notifications only" -deleteOutputs = "Delete outputs" -previewLoadFailed = "Could not load file preview." - -[watchedFolders.actions] -back = "Back" -dismiss = "Dismiss" -retry = "Retry" -download = "Download" -downloadInput = "Download input" -downloadOutput = "Download output" -collapse = "Collapse" -expand = "Expand" - -[watchedFolders.time] -justNow = "just now" -minutesAgo = "{{count}}m ago" -hoursAgo = "{{count}}h ago" -daysAgo = "{{count}}d ago" -[addStamp] -tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" - -[annotate] -tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand" - -[devAirgapped] -tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone" - -[devApi] -tags = "API,development,documentation,developer,REST,integration,endpoints,programmatic,automation,scripting" - -[devFolderScanning] -tags = "automation,folder,scanning,watch folder,hot folder,automatic processing,batch,monitor folder,auto process,folder monitoring" - -[devSsoGuide] -tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP" - -[read] -tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse" - -[scannerEffect] -tags = "scan,simulate,create,fake scan,look scanned,scanner effect,make look scanned,photocopy effect,simulate scanner,realistic scan" diff --git a/frontend/editor/src/core/constants/routes.ts b/frontend/editor/src/core/constants/routes.ts index 91af374cb..599a18830 100644 --- a/frontend/editor/src/core/constants/routes.ts +++ b/frontend/editor/src/core/constants/routes.ts @@ -14,6 +14,7 @@ export const AUTH_ROUTES = [ "/invite", "/forgot-password", "/reset-password", + "/oauth/consent", ]; /** diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx index 5aa5eb003..d3407f8a0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMcpSection.tsx @@ -30,6 +30,7 @@ interface McpAuthData { issuerUri?: string; jwksUri?: string; resourceId?: string; + acceptedAudiences?: string[]; usernameClaim?: string; requireExistingAccount?: boolean; } @@ -96,6 +97,7 @@ export default function AdminMcpSection() { "mcp.auth.issuerUri": s.auth?.issuerUri ?? "", "mcp.auth.jwksUri": s.auth?.jwksUri ?? "", "mcp.auth.resourceId": s.auth?.resourceId ?? "", + "mcp.auth.acceptedAudiences": s.auth?.acceptedAudiences ?? [], "mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub", "mcp.auth.requireExistingAccount": s.auth?.requireExistingAccount ?? true, @@ -269,6 +271,32 @@ export default function AdminMcpSection() { disabled={!settings.enabled} /> + + + {t( + "admin.settings.mcp.acceptedAudiences.label", + "Additional accepted audiences (optional)", + )} + + + + } + description={t( + "admin.settings.mcp.acceptedAudiences.description", + "Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated.", + )} + value={(settings.auth?.acceptedAudiences || []).join(" ")} + onChange={(e) => + setAuth({ acceptedAudiences: parseOpList(e.target.value) }) + } + placeholder="authenticated" + disabled={!settings.enabled} + /> + diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 7cbe522f5..d1eab6c69 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/utils/pathUtils"; import { AppProviders } from "@app/components/AppProviders"; import { setBaseUrl } from "@app/constants/app"; import type { AppConfig } from "@app/contexts/AppConfigContext"; @@ -11,6 +12,7 @@ import Login from "@app/routes/Login"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; +import OAuthConsent from "@app/routes/OAuthConsent"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; @@ -28,6 +30,25 @@ function handleConfigLoaded(config: AppConfig) { if (config.baseUrl) setBaseUrl(config.baseUrl); } +/** + * Onboarding and trial-expired modals must never cover auth-flow pages + * (login, signup, OAuth consent): they steal focus from the task the user + * was sent there to complete. Unmounting also stops their background polling. + */ +function NonAuthBootstraps() { + const location = useLocation(); + if (isAuthRoute(location.pathname)) { + return null; + } + return ( + <> + + + + + ); +} + export default function App() { return ( }> @@ -35,14 +56,13 @@ export default function App() { appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }} > - - - + } /> } /> } /> } /> + } /> } /> diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 4be5ed9b5..e57a46468 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { supabase, signInAnonymously } from "@app/auth/supabase"; import { useAuth } from "@app/auth/UseSession"; @@ -47,6 +47,25 @@ export default function Login() { } }, []); + // Same-origin relative path to return to after login (e.g. the OAuth + // consent page). Same sanitization rules as AuthCallback's `next`. + const nextPath = useMemo(() => { + try { + const next = new URL(window.location.href).searchParams.get("next"); + return next && next.startsWith("/") && !next.startsWith("//") + ? next + : null; + } catch (_) { + return null; + } + }, []); + + useEffect(() => { + if (session && !loading && nextPath) { + navigate(nextPath, { replace: true }); + } + }, [session, loading, nextPath, navigate]); + const baseUrl = getBaseUrl(); // Set document meta @@ -65,8 +84,11 @@ export default function Login() { ogUrl: `${window.location.origin}${window.location.pathname}`, }); - // Show logged in state if authenticated + // Show logged in state if authenticated (unless bouncing back to `next`) if (session && !loading) { + if (nextPath) { + return null; + } return ; } @@ -77,7 +99,9 @@ export default function Login() { setIsSigningIn(true); setError(null); - const redirectTo = absoluteWithBasePath("/auth/callback"); + const redirectTo = + absoluteWithBasePath("/auth/callback") + + (nextPath ? `?next=${encodeURIComponent(nextPath)}` : ""); console.log(`[Login] Signing in with ${provider}`); const oauthOptions: { @@ -169,7 +193,9 @@ export default function Login() { const { error } = await supabase.auth.signInWithOtp({ email: magicLinkEmail.trim(), options: { - emailRedirectTo: absoluteWithBasePath("/auth/callback"), + emailRedirectTo: + absoluteWithBasePath("/auth/callback") + + (nextPath ? `?next=${encodeURIComponent(nextPath)}` : ""), }, }); diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx new file mode 100644 index 000000000..0113edf62 --- /dev/null +++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx @@ -0,0 +1,410 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { useTranslation } from "@app/hooks/useTranslation"; +import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; +import AuthLayout from "@app/routes/authShared/AuthLayout"; +import "@app/routes/authShared/auth.css"; +import "@app/routes/authShared/saas-auth.css"; +import { withBasePath } from "@app/constants/app"; +import ErrorMessage from "@app/routes/login/ErrorMessage"; + +/** + * OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients + * such as Claude). Supabase redirects the user here as + * {SiteURL}/oauth/consent?authorization_id=X; this page loads the pending + * authorization, shows what the third-party app is asking for, and forwards + * the user's approve/deny decision back to Supabase, which then redirects to + * the requesting app with an authorization code (or an error). + * + * The installed supabase-js does not yet wrap these endpoints, so the GoTrue + * REST API is called directly with the user's session token. + */ + +interface AuthorizationDetails { + authorization_id: string; + redirect_uri?: string; + scope?: string; + client?: { + id?: string; + name?: string; + logo_uri?: string; + }; +} + +const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string; +const SUPABASE_KEY = import.meta.env + .VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY as string; + +async function gotrue( + path: string, + accessToken: string, + init?: RequestInit, +): Promise { + return fetch(`${SUPABASE_URL}/auth/v1/${path}`, { + ...init, + headers: { + apikey: SUPABASE_KEY, + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }); +} + +export default function OAuthConsent() { + const navigate = useNavigate(); + const location = useLocation(); + const { session, loading: sessionLoading, displayName } = useAuth(); + const { t } = useTranslation(); + + const [details, setDetails] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(true); + const [error, setError] = useState(null); + const [deciding, setDeciding] = useState<"approve" | "deny" | null>(null); + const [redirecting, setRedirecting] = useState(false); + + const authorizationId = useMemo(() => { + try { + return new URLSearchParams(location.search).get("authorization_id"); + } catch (_) { + return null; + } + }, [location.search]); + + useDocumentMeta({ + title: `${t("oauthConsent.title", "Authorize access")} - Stirling PDF`, + }); + + // Load the pending authorization once a session is available. + useEffect(() => { + if (sessionLoading || !session || !authorizationId) { + return; + } + let cancelled = false; + (async () => { + try { + setLoadingDetails(true); + const response = await gotrue( + `oauth/authorizations/${encodeURIComponent(authorizationId)}`, + session.access_token, + ); + const body = await response.json().catch(() => ({})); + if (cancelled) return; + if (!response.ok) { + console.error("[OAuthConsent] Failed to load authorization:", body); + setError( + t( + "oauthConsent.loadFailed", + "This authorization request is invalid or has expired. Close this tab and try connecting again from the app.", + ), + ); + } else { + setDetails(body as AuthorizationDetails); + } + } catch (err) { + console.error("[OAuthConsent] Unexpected error:", err); + if (!cancelled) { + setError( + t( + "oauthConsent.loadFailed", + "This authorization request is invalid or has expired. Close this tab and try connecting again from the app.", + ), + ); + } + } finally { + if (!cancelled) setLoadingDetails(false); + } + })(); + return () => { + cancelled = true; + }; + }, [sessionLoading, session, authorizationId, t]); + + const decide = useCallback( + async (action: "approve" | "deny") => { + if (!session || !authorizationId || deciding) return; + try { + setDeciding(action); + setError(null); + const response = await gotrue( + `oauth/authorizations/${encodeURIComponent(authorizationId)}/consent`, + session.access_token, + { method: "POST", body: JSON.stringify({ action }) }, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.redirect_url) { + console.error("[OAuthConsent] Consent call failed:", body); + setError( + t( + "oauthConsent.decisionFailed", + "Could not submit your decision. Please try again.", + ), + ); + setDeciding(null); + return; + } + // Send the browser back to the requesting app with the code (or error). + setRedirecting(true); + window.location.assign(body.redirect_url as string); + } catch (err) { + console.error("[OAuthConsent] Unexpected error:", err); + setError( + t( + "oauthConsent.decisionFailed", + "Could not submit your decision. Please try again.", + ), + ); + setDeciding(null); + } + }, + [session, authorizationId, deciding, t], + ); + + const appName = + details?.client?.name || + t("oauthConsent.unknownApp", "A third-party application"); + const scopes = (details?.scope || "") + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean); + + const scopeDescription = (scope: string): string => { + switch (scope) { + case "openid": + return t("oauthConsent.scope.openid", "Confirm your identity"); + case "email": + return t("oauthConsent.scope.email", "See your email address"); + case "profile": + return t( + "oauthConsent.scope.profile", + "See your basic profile information", + ); + case "phone": + return t("oauthConsent.scope.phone", "See your phone number"); + default: + return scope; + } + }; + + const logoBlock = ( +
+ Stirling PDF + Stirling PDF +
+ ); + + // Missing query parameter: nothing to act on. + if (!authorizationId) { + return ( + + {logoBlock} + + + ); + } + + // Not signed in: round-trip through login and come back here. + if (!sessionLoading && !session) { + const next = `${location.pathname}${location.search}`; + return ( + + {logoBlock} +

+ {t( + "oauthConsent.signInPrompt", + "Sign in to your Stirling PDF account to continue connecting the app.", + )} +

+ +
+ ); + } + + if (sessionLoading || loadingDetails || redirecting) { + return ( + + {logoBlock} +

+ {redirecting + ? t("oauthConsent.redirecting", "Returning you to the app...") + : t("oauthConsent.loading", "Loading authorization request...")} +

+
+ ); + } + + if (error && !details) { + return ( + + {logoBlock} + + + ); + } + + return ( + + {logoBlock} + + {/* AuthLayout forces light mode but text without explicit colors still + inherits dark-scheme values from the app CSS; pin them like the rest + of the auth pages do. */} +

+ {t("oauthConsent.title", "Authorize access")} +

+

+ {t("oauthConsent.requesting", { + app: appName, + defaultValue: `${appName} wants to access your Stirling PDF account`, + })} +

+ + {/* Be explicit about what connecting actually grants. The OAuth scopes + (openid/email) only cover identity; the real power is that the issued + token lets the app drive the MCP endpoint - i.e. run any Stirling PDF + tool as this user, audited as them and counted against their usage. */} +
+

+ {t("oauthConsent.scopesIntro", { + app: appName, + defaultValue: `This will allow ${appName} to:`, + })} +

+
    +
  • + {t("oauthConsent.access.tools", { + app: appName, + defaultValue: `Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents`, + })} +
  • +
  • + {t("oauthConsent.access.actAsYou", { + app: appName, + defaultValue: `Act as you - everything ${appName} does runs under your account and counts towards your usage`, + })} +
  • + {scopes.map((scope) => ( +
  • + {scopeDescription(scope)} +
  • + ))} +
+
+ + + +
+ + +
+ + {displayName && ( +

+ {t("oauthConsent.signedInAs", { + name: displayName, + defaultValue: `Signed in as ${displayName}`, + })} +

+ )} +
+ ); +} diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 8229c45ef..89bf6357d 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -115,14 +115,13 @@ apiClient.interceptors.request.use( }, ); -// List of endpoints that don't require authentication -const publicEndpoints = [ - "/api/v1/config/app-config", - "/api/v1/info/status", - "/api/v1/config/public-config", - "/api/v1/config/endpoints-availability", - "/api/v1/config/endpoint-enabled", -]; +// Endpoints whose 401s must never trigger the hard redirect to /login. Prefix +// matching covers the whole public surface (/api/v1/config/* and /api/v1/info/* +// are permitAll on the backend), and crucially includes background pings like +// /api/v1/info/wau that fire before session hydration - without this they bounce +// the user off pages that manage their own auth state (e.g. /oauth/consent) and +// lose URL state. +const publicEndpoints = ["/api/v1/config/", "/api/v1/info/"]; // Share one in-flight refresh: Supabase rotates the refresh token on first // use, so concurrent refreshSession() calls fail with "Already Used". diff --git a/frontend/editor/src/saas/utils/pathUtils.ts b/frontend/editor/src/saas/utils/pathUtils.ts index 1055df746..72abdf222 100644 --- a/frontend/editor/src/saas/utils/pathUtils.ts +++ b/frontend/editor/src/saas/utils/pathUtils.ts @@ -25,7 +25,13 @@ export function normalizePath(pathname: string): string { */ export function isAuthRoute(pathname: string): boolean { const p = normalizePath(pathname); - return p === "/login" || p === "/signup" || p === "/auth/callback"; + return ( + p === "/login" || + p === "/signup" || + p === "/auth/callback" || + p === "/auth/reset" || + p === "/oauth/consent" + ); } /** diff --git a/testing/compose/docker-compose-saas-mcp.override.yml b/testing/compose/docker-compose-saas-mcp.override.yml new file mode 100644 index 000000000..6c16457e4 --- /dev/null +++ b/testing/compose/docker-compose-saas-mcp.override.yml @@ -0,0 +1,34 @@ +# Overlay for docker-compose-saas.yml: switches on the MCP server inside the +# saas-flavor backend so the two-filter-chain interplay (Supabase chain at +# @Order(1) + MCP chain at @Order(0)) can be validated locally. +# +# The Supabase chain registers Spring Security 7's default RFC 9728 metadata +# filter for /.well-known/oauth-protected-resource/**. Without the MCP chain +# claiming those subpaths, the path-inserted discovery URL is answered by that +# default filter with a document missing authorization_servers, which makes MCP +# clients fall back to treating Stirling itself as the authorization server. +# +# Usage: +# docker-compose -f docker-compose-saas.yml -f docker-compose-saas-mcp.override.yml up -d --build +# MCP_AUTH_ISSUERURI= bash validate-mcp-saas-chain.sh +services: + stirling-pdf-saas: + environment: + # Base compose pins this to "disabled" (Supabase JWT enforcement off) for the payg + # cucumber tests. For full-stack MCP/consent testing against a real Supabase + # project, pass the project ref so the Supabase chain validates real tokens. + SAAS_DB_PROJECT_REF: "${SAAS_DB_PROJECT_REF:-disabled}" + MCP_ENABLED: "true" + MCP_AUTH_MODE: "oauth" + # Discovery-interplay checks only need a resolvable issuer URL; no tokens + # are minted against it. Point this at your real IdP to go further. + MCP_AUTH_ISSUERURI: "${MCP_AUTH_ISSUERURI:-https://auth.example.com}" + MCP_AUTH_RESOURCEID: "http://localhost:8080/mcp" + # Supabase's OAuth server cannot mint RFC 8707 resource audiences; its tokens always + # carry aud=authenticated. Accept it in addition to the resource id. + MCP_AUTH_ACCEPTEDAUDIENCES: "${MCP_AUTH_ACCEPTEDAUDIENCES:-}" + MCP_AUTH_USERNAMECLAIM: "${MCP_AUTH_USERNAMECLAIM:-email}" + MCP_AUTH_REQUIREEXISTINGACCOUNT: "${MCP_AUTH_REQUIREEXISTINGACCOUNT:-true}" + # Supabase only issues openid/profile/email/phone scopes, never mcp.tools.*; SaaS-style + # deployments therefore run with scope enforcement off. + MCP_SCOPESENABLED: "${MCP_SCOPESENABLED:-true}" diff --git a/testing/compose/validate-mcp-saas-chain.sh b/testing/compose/validate-mcp-saas-chain.sh new file mode 100755 index 000000000..1c2ea2605 --- /dev/null +++ b/testing/compose/validate-mcp-saas-chain.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Validate MCP OAuth discovery when the saas profile is active, i.e. when the +# Supabase security chain coexists with the MCP chain. Guards the regression +# where /.well-known/oauth-protected-resource/ fell through to the +# Supabase chain's default Spring Security metadata filter and was served +# WITHOUT authorization_servers, sending MCP clients to Stirling for OAuth. +# +# Prereq: docker-compose -f docker-compose-saas.yml -f docker-compose-saas-mcp.override.yml up +# Env: MCP_AUTH_ISSUERURI must match the issuer the stack was started with. + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +BASE_URL="${BASE_URL:-http://localhost:8080}" +ISSUER="${MCP_AUTH_ISSUERURI:-https://auth.example.com}" +PRM_URL="$BASE_URL/.well-known/oauth-protected-resource" +MCP_URL="$BASE_URL/mcp" + +PASS=0 +FAIL=0 +pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); } +fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); } + +echo -e "${BLUE}Validating MCP discovery with the saas profile (two-chain interplay)${NC}" +echo -e "${BLUE}Issuer:${NC} $ISSUER" +echo "" + +echo -e "${YELLOW}[0] Liveness${NC}" +if curl -sf "$BASE_URL/api/v1/info/status" 2>/dev/null | grep -q "UP"; then + pass "Stirling PDF (saas flavor) is UP" +else + fail "Stirling PDF is not UP at $BASE_URL" +fi +echo "" + +echo -e "${YELLOW}[1] Root metadata document${NC}" +ROOT_CODE=$(curl -s -o /tmp/saas_prm_root.json -w "%{http_code}" "$PRM_URL") +ROOT_BODY=$(cat /tmp/saas_prm_root.json 2>/dev/null) +[ "$ROOT_CODE" = "200" ] && pass "GET $PRM_URL -> 200" || fail "GET $PRM_URL -> $ROOT_CODE" +if echo "$ROOT_BODY" | grep -q "\"authorization_servers\"" && echo "$ROOT_BODY" | grep -qF "$ISSUER"; then + pass "root metadata advertises the configured authorization server" +else + fail "root metadata missing authorization_servers/issuer: $ROOT_BODY" +fi +echo "" + +echo -e "${YELLOW}[2] Path-inserted metadata document (the regression)${NC}" +SUB_CODE=$(curl -s -o /tmp/saas_prm_sub.json -w "%{http_code}" "$PRM_URL/mcp") +SUB_BODY=$(cat /tmp/saas_prm_sub.json 2>/dev/null) +[ "$SUB_CODE" = "200" ] && pass "GET $PRM_URL/mcp -> 200" || fail "GET $PRM_URL/mcp -> $SUB_CODE" +if echo "$SUB_BODY" | grep -q "\"authorization_servers\"" && echo "$SUB_BODY" | grep -qF "$ISSUER"; then + pass "path-inserted metadata served by the MCP chain (authorization_servers present)" +else + fail "path-inserted metadata fell through to the Supabase chain default filter: $SUB_BODY" +fi +if echo "$SUB_BODY" | grep -q "mcp.tools.read"; then + pass "path-inserted metadata advertises mcp.tools scopes" +else + fail "path-inserted metadata missing mcp.tools scopes" +fi +echo "" + +echo -e "${YELLOW}[3] 401 challenge points at the path-inserted metadata URL${NC}" +HDRS=$(curl -s -o /dev/null -D - -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') +CODE=$(echo "$HDRS" | head -1 | grep -o '[0-9][0-9][0-9]') +[ "$CODE" = "401" ] && pass "POST /mcp with no token -> 401" || fail "POST /mcp no token -> $CODE (expected 401)" +if echo "$HDRS" | grep -i '^WWW-Authenticate:' | grep -q 'oauth-protected-resource/mcp'; then + pass "WWW-Authenticate advertises resource_metadata at the path-inserted URL" +else + fail "WWW-Authenticate header wrong: $(echo "$HDRS" | grep -i '^WWW-Authenticate:')" +fi +echo "" + +echo -e "${YELLOW}[4] Chain isolation${NC}" +API_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/user/me") +if [ "$API_CODE" = "401" ] || [ "$API_CODE" = "403" ] || [ "$API_CODE" = "404" ]; then + pass "Supabase chain still guards regular API routes ($API_CODE)" +else + fail "Unexpected status on regular API route: $API_CODE" +fi +GARBAGE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer not-a-jwt" \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') +[ "$GARBAGE" = "401" ] && pass "garbage bearer token at /mcp -> 401" || fail "garbage token -> $GARBAGE" +echo "" + +echo -e "${BLUE}Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/testing/compose/validate-mcp-test.sh b/testing/compose/validate-mcp-test.sh index 149c7d436..f79ec5d2c 100644 --- a/testing/compose/validate-mcp-test.sh +++ b/testing/compose/validate-mcp-test.sh @@ -77,6 +77,30 @@ if echo "$PRM_BODY" | grep -q "mcp.tools"; then else fail "metadata missing mcp.tools scopes" fi + +# RFC 9728 path-inserted form: clients derive {origin}/.well-known/oauth-protected-resource/mcp +# for a resource at /mcp. This must serve the SAME customized metadata; a default document here +# (no authorization_servers) makes clients fall back to treating Stirling as its own AS. +PRM_SUB=$(curl -s -o /tmp/mcp_prm_sub.json -w "%{http_code}" "${PRM_URL}/mcp") +PRM_SUB_BODY=$(cat /tmp/mcp_prm_sub.json 2>/dev/null) +if [ "$PRM_SUB" = "200" ]; then + pass "GET ${PRM_URL}/mcp -> 200 (RFC 9728 path-inserted form)" +else + fail "GET ${PRM_URL}/mcp -> $PRM_SUB (expected 200)" +fi +if echo "$PRM_SUB_BODY" | grep -q "realms/$REALM"; then + pass "path-inserted metadata advertises the Keycloak authorization server" +else + fail "path-inserted metadata missing authorization_servers (filter-chain fall-through regression)" +fi +WWW_AUTH=$(curl -s -o /dev/null -D - -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' | grep -i '^WWW-Authenticate:') +if echo "$WWW_AUTH" | grep -q 'oauth-protected-resource/mcp'; then + pass "401 WWW-Authenticate advertises the path-inserted metadata URL" +else + fail "401 WWW-Authenticate lacks the path-inserted metadata URL: $WWW_AUTH" +fi echo "" # unauthenticated access is rejected diff --git a/testing/compose/walk-mcp-discovery.sh b/testing/compose/walk-mcp-discovery.sh new file mode 100755 index 000000000..5f3b45812 --- /dev/null +++ b/testing/compose/walk-mcp-discovery.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Walk the MCP OAuth discovery chain exactly like a spec-compliant client: +# 1. POST the MCP endpoint unauthenticated -> 401 + WWW-Authenticate resource_metadata +# 2. Fetch the advertised metadata URL AND the client-derived RFC 9728 +# path-inserted URL (clients use either; both must serve the real document) +# 3. Pick authorization_servers[0] and resolve its metadata via RFC 8414 +# path-aware discovery, falling back to OIDC discovery +# 4. Report authorize/token/registration endpoints; flag missing DCR support +# +# Usage: MCP_URL=https://host/mcp bash walk-mcp-discovery.sh +# Exits non-zero if discovery would strand a client (the fall-back-to-origin bug). + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +MCP_URL="${MCP_URL:-http://localhost:8080/mcp}" + +PASS=0; FAIL=0; WARN=0 +pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); } +fail() { echo -e "${RED}✗ $1${NC}"; FAIL=$((FAIL + 1)); } +warn() { echo -e "${YELLOW}⚠ $1${NC}"; WARN=$((WARN + 1)); } + +PY=python3; command -v python3 >/dev/null 2>&1 || PY=python +json_get() { # json_get , e.g. '.get("authorization_servers",[None])[0]' + "$PY" -c "import json,sys;d=json.load(open(sys.argv[1]));v=eval('d'+sys.argv[2]);print(v if v is not None else '')" "$1" "$2" 2>/dev/null +} + +ORIGIN=$(echo "$MCP_URL" | sed -E 's#^(https?://[^/]+).*#\1#') +RESOURCE_PATH=$(echo "$MCP_URL" | sed -E 's#^https?://[^/]+##') + +echo -e "${BLUE}MCP discovery walk for:${NC} $MCP_URL" +echo "" + +echo -e "${YELLOW}[1] Unauthenticated challenge${NC}" +HDRS=$(curl -s -o /dev/null -D - --max-time 15 -X POST "$MCP_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') +CODE=$(echo "$HDRS" | head -1 | grep -o '[0-9][0-9][0-9]' | head -1) +[ "$CODE" = "401" ] && pass "POST $MCP_URL -> 401" || fail "POST $MCP_URL -> $CODE (expected 401)" +ADVERTISED=$(echo "$HDRS" | tr -d '\r' | grep -i '^WWW-Authenticate:' | sed -n 's/.*resource_metadata="\([^"]*\)".*/\1/p') +if [ -n "$ADVERTISED" ]; then + pass "WWW-Authenticate advertises resource_metadata: $ADVERTISED" +else + fail "no resource_metadata in WWW-Authenticate header" +fi +echo "" + +echo -e "${YELLOW}[2] Protected resource metadata (advertised + path-inserted)${NC}" +DERIVED="$ORIGIN/.well-known/oauth-protected-resource$RESOURCE_PATH" +AS_URL="" +for URL in "$ADVERTISED" "$DERIVED"; do + [ -z "$URL" ] && continue + BODY_FILE=$(mktemp) + HTTP=$(curl -s -o "$BODY_FILE" -w "%{http_code}" --max-time 15 "$URL") + if [ "$HTTP" != "200" ]; then + fail "GET $URL -> $HTTP (expected 200)" + continue + fi + AS=$(json_get "$BODY_FILE" '.get("authorization_servers",[None])[0]') + RES=$(json_get "$BODY_FILE" '.get("resource")') + if [ -n "$AS" ]; then + pass "GET $URL -> resource=$RES, authorization_server=$AS" + AS_URL="$AS" + else + fail "GET $URL has NO authorization_servers - a client using this URL falls back to $ORIGIN as its OAuth server" + fi +done +echo "" + +echo -e "${YELLOW}[3] Authorization server metadata (RFC 8414 / OIDC)${NC}" +if [ -z "$AS_URL" ]; then + fail "no authorization server discovered; cannot continue" +else + AS_ORIGIN=$(echo "$AS_URL" | sed -E 's#^(https?://[^/]+).*#\1#') + AS_PATH=$(echo "$AS_URL" | sed -E 's#^https?://[^/]+##') + ASM_FILE=$(mktemp) + FOUND="" + for CAND in \ + "$AS_ORIGIN/.well-known/oauth-authorization-server$AS_PATH" \ + "$AS_ORIGIN/.well-known/openid-configuration$AS_PATH" \ + "$AS_URL/.well-known/openid-configuration"; do + HTTP=$(curl -s -o "$ASM_FILE" -w "%{http_code}" --max-time 15 "$CAND") + ISS=$(json_get "$ASM_FILE" '.get("issuer")') + if [ "$HTTP" = "200" ] && [ -n "$ISS" ]; then + pass "AS metadata found at $CAND" + FOUND=1 + break + fi + done + if [ -z "$FOUND" ]; then + fail "no AS metadata at any well-known location for $AS_URL" + else + AUTHZ=$(json_get "$ASM_FILE" '.get("authorization_endpoint")') + TOKEN=$(json_get "$ASM_FILE" '.get("token_endpoint")') + REG=$(json_get "$ASM_FILE" '.get("registration_endpoint")') + SCOPES=$(json_get "$ASM_FILE" '.get("scopes_supported")') + [ -n "$AUTHZ" ] && pass "authorization_endpoint: $AUTHZ" || fail "no authorization_endpoint" + [ -n "$TOKEN" ] && pass "token_endpoint: $TOKEN" || fail "no token_endpoint" + if [ -n "$REG" ]; then + pass "registration_endpoint: $REG (dynamic client registration available)" + else + warn "no registration_endpoint - clients needing DCR (Claude etc.) cannot self-register; pre-register a client or enable DCR on the IdP" + fi + echo -e " ${BLUE}scopes_supported:${NC} $SCOPES" + case "$SCOPES" in + *mcp.tools*) pass "IdP can issue mcp.tools.* scopes" ;; + *) warn "IdP does not advertise mcp.tools.* scopes - run Stirling with MCP_SCOPESENABLED=false or add the scopes to the IdP" ;; + esac + fi +fi +echo "" + +echo -e "${BLUE}Results: ${GREEN}$PASS passed${NC}, ${YELLOW}$WARN warnings${NC}, ${RED}$FAIL failed${NC}" +[ "$FAIL" -eq 0 ] || exit 1 From 33026e1a82750450b4f4bd8079da687a463a3b52 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:39:02 +0100 Subject: [PATCH 44/80] update saas onboarding (#6619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2026-06-11 at 6 20 10 PM --- .../public/locales/en-GB/translation.toml | 149 +++++--------- .../InitialOnboardingModal.module.css | 11 +- .../onboarding/slides/DesktopInstallSlide.tsx | 7 +- .../slides/unifiedBackgroundConfig.ts | 38 +++- frontend/editor/src/core/types/types.ts | 2 + .../policies/PolicyDeleteConfirmModal.tsx | 4 +- .../onboarding/SaasOnboardingModal.tsx | 27 ++- .../components/onboarding/saasFlowResolver.ts | 55 ++--- .../onboarding/saasOnboardingFlowConfig.ts | 95 ++++----- .../onboarding/slides/FreeEditorSlide.tsx | 44 ++++ .../onboarding/slides/FreeTrialSlide.tsx | 108 ---------- .../slides/SaasOnboardingSlides.module.css | 91 +++++++++ .../onboarding/slides/TeamSlide.tsx | 191 ++++++++++++++++++ .../onboarding/slides/UsageSnapshotSlide.tsx | 45 +++++ .../onboarding/useSaasOnboardingState.ts | 30 ++- .../shared/config/configSections/Payg.tsx | 47 +++-- .../shared/config/configSections/PaygFree.css | 4 +- .../shared/config/configSections/PaygFree.tsx | 26 ++- .../shared/config/configSections/Plan.tsx | 5 +- .../config/configSections/SpendCapControl.css | 9 +- .../config/configSections/SpendCapControl.tsx | 10 +- .../configSections/StripeCheckoutPanel.tsx | 7 +- .../config/configSections/UpgradeModal.css | 13 +- .../config/configSections/UpgradeModal.tsx | 43 ++-- .../shared/config/saasConfigNavSections.tsx | 6 +- .../src/saas/routes/authShared/saas-auth.css | 1 - .../services/paygErrorInterceptor.test.ts | 4 +- 27 files changed, 659 insertions(+), 413 deletions(-) create mode 100644 frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx delete mode 100644 frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css create mode 100644 frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 214c05f16..050104088 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -50,12 +50,9 @@ help = "Help" imgPrompt = "Select Image(s)" incorrectPasswordMessage = "Current password is incorrect." info = "Info" -insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." -loadingCredits = "Checking credits..." -loadingProStatus = "Checking subscription status..." logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" @@ -66,7 +63,6 @@ noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" nothingToUndo = "Nothing to undo" -noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" noValidFiles = "No valid files to process" oops = "Oops!" openInNewWindow = "Open in new window" @@ -3099,18 +3095,6 @@ integration = "Integration Configuration" security = "Security Configuration" system = "System Configuration" -[config.payg] -label = "Billing & usage" -section = "Pay-as-you-go" - -[connectionMode.status] -localOffline = "Offline mode running" -localOnline = "Offline mode running" -saas = "Connected to Stirling Cloud" -selfhostedChecking = "Connected to self-hosted server (checking...)" -selfhostedOffline = "Self-hosted server unreachable" -selfhostedOnline = "Connected to self-hosted server" - [convert] autoRotate = "Auto Rotate" autoRotateDescription = "Automatically rotate images to better fit the PDF page" @@ -4903,15 +4887,6 @@ body = "Stirling works best as a desktop app. You can use it offline, access doc title = "Download" titleWithOs = "Download for {{osLabel}}" -[onboarding.freeTrial] -afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access." -afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends." -body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing." -daysRemaining = "{{days}} days remaining" -daysRemainingSingular = "{{days}} day remaining" -title = "Your 30-Day Pro Trial" -trialEnds = "Trial ends {{date}}" - [onboarding.mfa] authenticationCode = "Authentication code" qrCodeLoading = "Generating your QR code…" @@ -4923,6 +4898,22 @@ adminTitle = "Admin Overview" userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" +[onboarding.saas.freeEditor] +freeLine = "And the best part? The editor is now completely free." +premium = "We've added a whole host of new features, including Policies and Agent Chat." +title = "Welcome to Stirling" + +[onboarding.saas.team] +addButton = "Add" +createBody = "Work on documents together — teammates share files, automations and your plan. Add the first member by email to create your team." +createTitle = "Create your team" +inviteBody = "Everyone on your team shares files, automations and your plan. Add teammates by email — they'll get an invite right away." +inviteTitle = "Invite members to your team" + +[onboarding.saas.usage] +body = "Automations, AI and API requests draw from your one-time free allowance. Here's where you stand - manual editing never counts against it." +title = "Your free Processor allowance" + [onboarding.securityCheck] message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue." roleAdmin = "Admin" @@ -5193,50 +5184,24 @@ title = "Page Ranges" bullet1 = "all → selects all pages" title = "Special Keywords" -[pageSelection.tooltip.syntax] -text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported." -title = "Syntax Basics" - -[pageSelection.tooltip.syntax.bullets] -keywords = "Keywords: odd, even" -numbers = "Numbers/ranges: 5, 10-20" -progressions = "Progressions: 3n, 4n+1" - -[pageSelection.tooltip.tips] -bullet1 = "Page numbers start from 1 (not 0)" -bullet2 = "Spaces are automatically removed" -bullet3 = "Invalid expressions are ignored" -text = "Keep these guidelines in mind:" -title = "Tips" - -[payg] -subtitle = "Pay-as-you-go: you only pay for what you process. Billing period {{start}} – {{end}}." - [payg.activity] +docs = "docs" +empty = "No billable activity yet this period." subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here." title = "Recent billable activity" -units = "units" -viewAll = "View all" [payg.cap] amount = "Cap amount" -currency = "Currency" custom = "Custom" -degradeAt = "Limit spend at" -degradeAtDesc = "Set below 100% to leave yourself headroom." docsEstimate = "≈ {{docs}} processed PDFs / month" docsRate = "at {{rate}} / PDF" noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time." noCapLabel = "No cap" +noneShort = "No cap" perMonth = "/ month" -preview = "{{money}} ≈ {{units}} PDFs per month" -previewNote = "At current pricing. Final translation happens server-side on save." -previewShort = "≈ {{units}} PDFs per month at current pricing." save = "Update cap" subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier." title = "Monthly spending cap" -warnAt = "Warn me at" -warnAtDesc = "Notify when usage crosses this threshold." [payg.checkout] connecting = "Connecting to Stripe…" @@ -5259,6 +5224,13 @@ note = "You can change your cap, cancel, or open the Stripe customer portal any summaryLabel = "Monthly ceiling" title = "Welcome to the Processor plan" +[payg.docHelp] +billable = "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free." +chains = "Running the same file through several steps of one automation counts it once, not once per step." +perFile = "Each file you process counts as one PDF. Very long or very large files can count as more than one." +refunds = "If a job fails on its first step, the PDF is credited back automatically." +toggle = "What counts as a PDF?" + [payg.error] body = "We couldn't reach the billing service. Refresh the page to try again." title = "Couldn't load your plan" @@ -5283,29 +5255,17 @@ title = "Turn on the Processor plan" [payg.free.editor] eyebrow = "Editor plan · Always free" -[payg.free.explainer] -alwaysFreeBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from." -alwaysFreeForYouBody = "Manual tools: viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted." -alwaysFreeForYouLabel = "Always free for you" -alwaysFreeLabel = "Always free" -countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor." -countsLabel = "Counts toward your 500 free" -sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor." -sharedLabel = "Shared with the team" - [payg.free.header] -subtitle = "Editor plan: manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}." +freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." +freeTitle = "Unlimited PDF editing" [payg.free.hero] capSuffix = "/ {{limit}} free PDFs" -eyebrow = "Your free PDFs" metaCategories = "Automation · AI · API requests" neverResets = "One-time, never resets" [payg.free.member] -body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like. Automation, AI, and API access share the team's one-time allowance of 500 free PDFs." ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like." -title = "Want to process more than your 500 free PDFs?" [payg.free.proc] eyebrow = "Processor plan · metered" @@ -5321,9 +5281,17 @@ automation = "Automations & pipelines" client = "Browser-only tools (viewer, page editor, file management)" offsite = "Server tools (compress, OCR, convert, watermark…)" pauses = "pauses at cap" -subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised." title = "What happens when the cap is reached" +[payg.header] +eyebrow = "Processor plan · {{start}} – {{end}}" +freeBody = "View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it." +freeLabel = "Always free" +freeTitle = "Unlimited PDF editing" +meterBody = "{{limit}} free PDFs to start, then billed per PDF up to your cap." +meterLabel = "Metered" +meterTitle = "Automation · AI · API" + [payg.member] askLeader = "Only your team owner can change the cap." @@ -5364,26 +5332,20 @@ body = "Billing portal isn't available right now. Try again in a moment." title = "Billing portal unavailable" [payg.upgrade] +backAria = "Back" closeAria = "Close" [payg.upgrade.button] -back = "← Back" cancel = "Cancel" continue = "Continue →" finish = "Finish" [payg.upgrade.cap] -customPlaceholder = "Or enter your own amount ($0 keeps it free)" help = "We'll never charge above this. Set $0 if you want to keep everything free while testing." -noCapHint = "You can still cancel anytime from the customer portal." -noCapLabel = "No cap, I'll manage spend manually" -perMonthSuffix = "/ month" -presetsAria = "Monthly cap preset" title = "Set your monthly spend ceiling" usdNote = "Estimated in USD. You can adjust your cap any time after subscribing, in your own currency." [payg.upgrade.checkout] -capLabel = "Monthly ceiling:" capValue = "{{symbol}}{{amount}} / month" edit = "Edit" help = "Stripe handles your card details. Stirling never sees them." @@ -5391,11 +5353,6 @@ loading = "Loading checkout…" noCap = "No cap" title = "Add your payment method" -[payg.upgrade.footer] -capHint = "You can change your cap any time later." -checkoutHint = "Card details handled by Stripe, never touched by Stirling." -confirmHint = "Your wallet will refresh automatically in a moment." - [payg.upgrade.help] aiBody = "summarise, classify, redact, AI-OCR" aiTitle = "AI tools" @@ -5419,11 +5376,15 @@ confirm = "You're subscribed" default = "Upgrade to Processor plan" [payg.usage] -credit = "{{amount}} account credit" -docs = "documents" +breakdown = "AI {{ai}} • Automation {{automation}} • API {{api}}" +capLine = "{{cap}}/mo cap" +estBill = "≈ {{amount}} so far this period" +firstFree = "First {{free}} free" +noCap = "No monthly cap" +ofLimitProcessed = "/ {{limit}} PDFs processed" +processed = "PDFs processed" resetsIn = "Resets in {{days}} days" resetsTomorrow = "Resets tomorrow" -spent = "{{spend}} of {{cap}} used" thisPeriod = "This billing period" [payment] @@ -5749,28 +5710,17 @@ manage = "Manage" perMonth = "/month" perSeat = "/seat" popular = "Popular" -purchase = "Purchase" -selectCredits = "Select Credit Amount" selectPlan = "Select Plan" showComparison = "Compare All Features" -totalCost = "Total Cost" upgrade = "Upgrade" withServer = "+ Server Plan" -[plan.activePlan] -subtitle = "Your current subscription details" -title = "Active Plan" - [plan.api] large = "5,000 Credits" medium = "1,000 Credits" small = "500 Credits" xsmall = "100 Credits" -[plan.apiPackages] -subtitle = "Purchase API credits for your applications" -title = "API Credit Packages" - [plan.availablePlans] loadError = "Unable to load plan pricing. Using default values." subtitle = "Choose the plan that fits your needs" @@ -5881,18 +5831,17 @@ successMessage = "Your license has been successfully activated. You can now clos name = "Team" [plan.trial] -badge = "Trial" continueWithFree = "Continue with Free" -daysRemaining = "Your trial ends in {{days}} days" -endDate = "Expires: {{date}}" expired = "Your Trial Has Ended" expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier." freeTierLimitations = "Free tier includes basic PDF tools with usage limits." message = "" subscribe = "Subscribe to Pro" subscribeToPro = "Subscribe to Pro" -subscriptionScheduled = "Subscription scheduled - starts {{date}}" -title = "Free Trial Active" + +[policies] +deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." +deleteConfirmTitle = "Delete {{label}} policy?" [policies] deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index 5b3aec2d9..c1d88f46c 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -34,8 +34,8 @@ } .standaloneIcon { - width: 96px; - height: 96px; + width: 80px; + height: 80px; object-fit: contain; animation: heroLogoScale 0.25s ease forwards; } @@ -124,8 +124,6 @@ align-items: flex-start; justify-content: center; animation: heroLogoEnter 0.25s ease forwards; - position: relative; - top: 1rem; } .iconWrapper { @@ -175,8 +173,8 @@ } .downloadIcon { - width: 96px; - height: 96px; + width: 80px; + height: 80px; object-fit: contain; animation: heroLogoScale 0.25s ease forwards; } @@ -205,6 +203,7 @@ } .bodyCopy { + text-align: center; opacity: 0; transform: translateX(24px); animation: bodySlideIn 0.25s ease forwards; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index eb3e74352..d8549d0ea 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { SlideConfig } from "@app/types/types"; -import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import { UNIFIED_LIGHT_BACKGROUND } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { DesktopInstallTitle, type OSOption, @@ -47,9 +47,6 @@ export default function DesktopInstallSlide({ ), body: , downloadUrl: osUrl, - background: { - gradientStops: ["#2563EB", "#0EA5E9"], - circles: UNIFIED_CIRCLE_CONFIG, - }, + background: UNIFIED_LIGHT_BACKGROUND, }; } diff --git a/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts b/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts index 33ac2d291..a0a8c77d1 100644 --- a/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts +++ b/frontend/editor/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts @@ -1,4 +1,7 @@ -import { AnimatedCircleConfig } from "@app/types/types"; +import { + AnimatedCircleConfig, + AnimatedSlideBackgroundProps, +} from "@app/types/types"; /** * Unified circle background configuration used across all onboarding slides. @@ -27,3 +30,36 @@ export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [ offsetY: 18, }, ]; + +/** + * Build a light slide background in a slide-specific accent colour: a white + * hero fading into a pale horizon tint, with the unified sphere geometry + * glowing in the accent on each sphere's inner edge. + */ +export function createLightSlideBackground( + accentRgb: [number, number, number], + horizon: string, +): AnimatedSlideBackgroundProps { + const [r, g, b] = accentRgb; + const sphereColors = [ + `linear-gradient(45deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.3))`, + `linear-gradient(225deg, rgba(${r}, ${g}, ${b}, 0.04), rgba(${r}, ${g}, ${b}, 0.26))`, + ]; + return { + gradientStops: ["#FFFFFF", horizon], + circles: UNIFIED_CIRCLE_CONFIG.map((circle, index) => ({ + ...circle, + color: sphereColors[index], + })), + tone: "light", + }; +} + +/** + * Default light slide background: white with a light blue horizon and + * blue-glow spheres. + */ +export const UNIFIED_LIGHT_BACKGROUND = createLightSlideBackground( + [37, 99, 235], + "#DBEAFE", +); diff --git a/frontend/editor/src/core/types/types.ts b/frontend/editor/src/core/types/types.ts index fd37a20d6..29133ac09 100644 --- a/frontend/editor/src/core/types/types.ts +++ b/frontend/editor/src/core/types/types.ts @@ -16,6 +16,8 @@ export interface AnimatedCircleConfig { export interface AnimatedSlideBackgroundProps { gradientStops: [string, string]; circles: AnimatedCircleConfig[]; + /** Overall background tone; controls on top of the hero (e.g. close button) adapt to stay visible. Defaults to "dark". */ + tone?: "light" | "dark"; } export interface SlideConfig { diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx index 02b46928c..0d40660d1 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx @@ -21,7 +21,9 @@ export function PolicyDeleteConfirmModal({ diff --git a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx index 4af91fd02..491aad7dc 100644 --- a/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/saas/components/onboarding/SaasOnboardingModal.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Modal, Stack } from "@mantine/core"; -import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; +import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; +import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded"; import { useTranslation } from "react-i18next"; -import LocalIcon from "@app/components/shared/LocalIcon"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; import { renderButtons } from "@app/components/onboarding/renderButtons"; @@ -48,18 +48,23 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { ); } + if (slideDefinition.hero.type === "logo") { + return ( + Stirling logo + ); + } + return (
- {slideDefinition.hero.type === "rocket" && ( - + {slideDefinition.hero.type === "bolt" && ( + )} - {slideDefinition.hero.type === "diamond" && ( - + {slideDefinition.hero.type === "team" && ( + )}
); diff --git a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts index 539418a0c..ed723852c 100644 --- a/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/saas/components/onboarding/saasFlowResolver.ts @@ -1,42 +1,25 @@ -import { TrialStatus } from "@app/auth/UseSession"; -import { - FLOW_SEQUENCES, - SlideId, -} from "@app/components/onboarding/saasOnboardingFlowConfig"; +import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; -export interface FlowConfig { - type: "saas-trial" | "saas-paid"; - ids: SlideId[]; +export interface SaasFlowInputs { + /** Free-tier wallet with one-time allowance remaining — show the usage meter. */ + showUsageSlide: boolean; + /** Team leaders only — invited members and anonymous guests skip the team slide. */ + showTeamSlide: boolean; } /** - * Resolves the appropriate onboarding flow based on user's subscription status. - * - * @param trialStatus - User's trial information from Supabase - * @param _isPro - Whether user has Pro subscription - * @returns FlowConfig with the appropriate slide sequence + * Resolves the SaaS onboarding slide sequence. The free-editor pitch and + * desktop install bookend the flow; the usage meter and team slides slot in + * when their conditions hold. */ -export function resolveSaasFlow( - trialStatus: TrialStatus | null, - _isPro: boolean | null, -): FlowConfig { - // Show free trial card if: - // 1. User has active trial (isTrialing = true) - // 2. Trial has not expired (daysRemaining > 0) - // 3. User is not paid Pro (or Pro is from trial) - const hasActiveTrial = - trialStatus?.isTrialing === true && trialStatus.daysRemaining > 0; - - if (hasActiveTrial) { - return { - type: "saas-trial", - ids: FLOW_SEQUENCES.saasTrialUser, - }; - } - - // For paid users, expired trials, or no trial info - return { - type: "saas-paid", - ids: FLOW_SEQUENCES.saasPaidUser, - }; +export function resolveSaasFlow({ + showUsageSlide, + showTeamSlide, +}: SaasFlowInputs): SlideId[] { + return [ + "free-editor", + ...(showUsageSlide ? (["usage"] as const) : []), + ...(showTeamSlide ? (["team"] as const) : []), + "desktop-install", + ]; } diff --git a/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts index 342e1fe87..91af54611 100644 --- a/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts +++ b/frontend/editor/src/saas/components/onboarding/saasOnboardingFlowConfig.ts @@ -1,12 +1,12 @@ -import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; +import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide"; +import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide"; +import TeamSlide from "@app/components/onboarding/slides/TeamSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; -import FreeTrialSlide from "@app/components/onboarding/slides/FreeTrialSlide"; import { SlideConfig } from "@app/types/types"; -import { TrialStatus } from "@app/auth/UseSession"; -export type SlideId = "welcome" | "free-trial" | "desktop-install"; +export type SlideId = "free-editor" | "usage" | "team" | "desktop-install"; -export type HeroType = "rocket" | "dual-icon" | "diamond"; +export type HeroType = "logo" | "bolt" | "team" | "dual-icon"; export type ButtonAction = "next" | "prev" | "close" | "download-selected"; @@ -23,7 +23,6 @@ export interface SlideFactoryParams { osUrl: string; osOptions?: OSOption[]; onDownloadUrlChange?: (url: string) => void; - trialStatus?: TrialStatus | null; } export interface HeroDefinition { @@ -48,47 +47,46 @@ export interface SlideDefinition { buttons: ButtonDefinition[]; } +const BACK_BUTTON: ButtonDefinition = { + key: "back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", +}; + +const NEXT_BUTTON: ButtonDefinition = { + key: "next", + type: "button", + label: "onboarding.buttons.next", + variant: "primary", + group: "right", + action: "next", +}; + export const SLIDE_DEFINITIONS: Record = { - welcome: { - id: "welcome", - createSlide: () => WelcomeSlide(), - hero: { type: "rocket" }, + "free-editor": { + id: "free-editor", + createSlide: () => FreeEditorSlide(), + hero: { type: "logo" }, + buttons: [{ ...NEXT_BUTTON, key: "free-editor-next" }], + }, + usage: { + id: "usage", + createSlide: () => UsageSnapshotSlide(), + hero: { type: "bolt" }, buttons: [ - { - key: "welcome-next", - type: "button", - label: "onboarding.buttons.next", - variant: "primary", - group: "right", - action: "next", - }, + { ...BACK_BUTTON, key: "usage-back" }, + { ...NEXT_BUTTON, key: "usage-next" }, ], }, - "free-trial": { - id: "free-trial", - createSlide: ({ trialStatus }) => { - if (!trialStatus) { - throw new Error("Trial status is required for free-trial slide"); - } - return FreeTrialSlide({ trialStatus }); - }, - hero: { type: "diamond" }, + team: { + id: "team", + createSlide: () => TeamSlide(), + hero: { type: "team" }, buttons: [ - { - key: "trial-back", - type: "icon", - icon: "chevron-left", - group: "left", - action: "prev", - }, - { - key: "trial-next", - type: "button", - label: "onboarding.buttons.next", - variant: "primary", - group: "right", - action: "next", - }, + { ...BACK_BUTTON, key: "team-back" }, + { ...NEXT_BUTTON, key: "team-next" }, ], }, "desktop-install": { @@ -97,13 +95,7 @@ export const SLIDE_DEFINITIONS: Record = { DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), hero: { type: "dual-icon" }, buttons: [ - { - key: "desktop-back", - type: "icon", - icon: "chevron-left", - group: "left", - action: "prev", - }, + { ...BACK_BUTTON, key: "desktop-back" }, { key: "desktop-skip", type: "button", @@ -123,8 +115,3 @@ export const SLIDE_DEFINITIONS: Record = { ], }, }; - -export const FLOW_SEQUENCES = { - saasTrialUser: ["welcome", "free-trial", "desktop-install"] as SlideId[], - saasPaidUser: ["welcome", "desktop-install"] as SlideId[], -}; diff --git a/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx new file mode 100644 index 000000000..1aca39931 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/FreeEditorSlide.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Trans } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css"; + +// Stirling logo red (sampled from modern-logo/logo512.png) +const FREE_EDITOR_BACKGROUND = createLightSlideBackground( + [142, 49, 49], + "#F8E0E0", +); + +const FreeEditorBody = () => ( + + }} + defaults="We've added a whole host of new features, including Policies and Agent Chat." + /> + + }} + defaults="And the best part? The editor is now completely free." + /> + + +); + +export default function FreeEditorSlide(): SlideConfig { + const title = ( + + ); + + return { + key: "free-editor", + title, + body: , + background: FREE_EDITOR_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx deleted file mode 100644 index 70ad08c98..000000000 --- a/frontend/editor/src/saas/components/onboarding/slides/FreeTrialSlide.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { SlideConfig } from "@app/types/types"; -import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; -import { TrialStatus } from "@app/auth/UseSession"; - -interface FreeTrialSlideProps { - trialStatus: TrialStatus; -} - -function FreeTrialSlideTitle() { - const { t } = useTranslation(); - - return ( - {t("onboarding.freeTrial.title", "Your 30-Day Pro Trial")} - ); -} - -const FreeTrialSlideBody = ({ trialStatus }: { trialStatus: TrialStatus }) => { - const { t } = useTranslation(); - - // Format the trial end date - const trialEndDate = new Date(trialStatus.trialEnd).toLocaleDateString( - undefined, - { - year: "numeric", - month: "long", - day: "numeric", - }, - ); - - // Determine which message to show based on payment method - const afterTrialMessage = trialStatus.hasScheduledSub - ? t( - "onboarding.freeTrial.afterTrialWithPayment", - "Your Pro subscription will start automatically when the trial ends.", - ) - : trialStatus.hasPaymentMethod - ? t( - "onboarding.freeTrial.afterTrialWithPayment", - "Your Pro subscription will start automatically when the trial ends.", - ) - : t( - "onboarding.freeTrial.afterTrialWithoutPayment", - "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access.", - ); - - // Pluralize days remaining - const daysText = - trialStatus.daysRemaining === 1 - ? t( - "onboarding.freeTrial.daysRemainingSingular", - "{{days}} day remaining", - { days: trialStatus.daysRemaining }, - ) - : t("onboarding.freeTrial.daysRemaining", "{{days}} days remaining", { - days: trialStatus.daysRemaining, - }); - - return ( -
-

- {t( - "onboarding.freeTrial.body", - "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing.", - )} -

-
-
- {daysText} -
-
- {t("onboarding.freeTrial.trialEnds", "Trial ends {{date}}", { - date: trialEndDate, - })} -
-
-

{afterTrialMessage}

-
- ); -}; - -export default function FreeTrialSlide({ - trialStatus, -}: FreeTrialSlideProps): SlideConfig { - return { - key: "free-trial", - title: , - body: , - background: { - gradientStops: ["#10B981", "#06B6D4"], - circles: UNIFIED_CIRCLE_CONFIG, - }, - }; -} diff --git a/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css b/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css new file mode 100644 index 000000000..3fcdb9383 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/SaasOnboardingSlides.module.css @@ -0,0 +1,91 @@ +/* Shared styles for the SaaS onboarding slides */ + +/* ── Slide 1: free editor pitch ─────────────────────────────────────────── */ + +.freeLine { + display: block; + margin-top: 10px; +} + +.freeHighlight { + background: linear-gradient(135deg, #6366f1, #ec4899); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-weight: 700; +} + +/* ── Slide 2: usage meter snapshot ──────────────────────────────────────── */ + +.usageMeterWrap { + max-width: 420px; + margin: 16px auto 0; + text-align: left; +} + +/* ── Slide 3: team ──────────────────────────────────────────────────────── */ + +.teamCard { + max-width: 460px; + margin: 12px auto 0; + text-align: left; +} + +.memberList { + display: flex; + flex-direction: column; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--border-default, #d1d5db); + border-radius: 12px; + margin-bottom: 12px; +} + +.memberRow { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; +} + +.memberRow + .memberRow { + border-top: 1px solid var(--border-subtle, #e5e7eb); +} + +.memberIdentity { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.memberName { + font-size: 14px; + font-weight: 600; + color: var(--onboarding-title, #111827); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.memberEmail { + font-size: 12px; + color: var(--onboarding-body, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inviteRow { + display: flex; + gap: 8px; +} + +.inviteRow > :first-child { + flex: 1; +} + +.inviteFeedback { + font-size: 13px; + margin-top: 8px; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx new file mode 100644 index 000000000..1bc78a837 --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/TeamSlide.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useState } from "react"; +import { Badge, Button, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; +import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css"; + +const TEAM_BACKGROUND = createLightSlideBackground([79, 70, 229], "#E0E7FF"); + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function useHasTeamMembers(): boolean { + const { teamMembers, teamInvitations } = useSaaSTeam(); + const pendingInvitations = teamInvitations.filter( + (invitation) => invitation.status === "PENDING", + ); + // The leader themselves is always in teamMembers, so "has a team" means + // anyone beyond them — or an invite already on its way. + return teamMembers.length > 1 || pendingInvitations.length > 0; +} + +function TeamSlideTitle() { + const { t } = useTranslation(); + const hasTeam = useHasTeamMembers(); + + return hasTeam + ? t("onboarding.saas.team.inviteTitle", "Invite members to your team") + : t("onboarding.saas.team.createTitle", "Create your team"); +} + +function InviteForm() { + const { t } = useTranslation(); + const { inviteUser } = useSaaSTeam(); + const [email, setEmail] = useState(""); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const emailValid = EMAIL_PATTERN.test(email); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + if (!emailValid) return; + + setInviting(true); + setError(null); + setSuccess(null); + + try { + await inviteUser(email); + setSuccess( + t("team.inviteSent", "Invitation sent to {{email}}", { email }), + ); + setEmail(""); + } catch (err) { + const inviteError = err as { response?: { data?: { error?: string } } }; + setError( + inviteError.response?.data?.error || + t("team.inviteError", "Failed to send invitation"), + ); + } finally { + setInviting(false); + } + }; + + return ( +
+ + setEmail(e.target.value)} + error={ + email && !emailValid + ? t("team.invite.invalidEmail", "Invalid email format") + : undefined + } + /> + + + {error && ( + + {error} + + )} + {success && ( + + {success} + + )} +
+ ); +} + +const TeamSlideBody = () => { + const { t } = useTranslation(); + const { teamMembers, teamInvitations, refreshTeams } = useSaaSTeam(); + const hasTeam = useHasTeamMembers(); + const pendingInvitations = teamInvitations.filter( + (invitation) => invitation.status === "PENDING", + ); + + // Onboarding shows right after first login — make sure team data is fresh. + useEffect(() => { + refreshTeams(); + }, []); + + return ( + + {hasTeam + ? t( + "onboarding.saas.team.inviteBody", + "Everyone on your team shares files, automations and your plan. Add teammates by email — they'll get an invite right away.", + ) + : t( + "onboarding.saas.team.createBody", + "Work on documents together — teammates share files, automations and your plan. Add the first member by email to create your team.", + )} + + {hasTeam && ( + + {teamMembers.map((member) => ( + + + {member.username} + {member.email} + + + {member.role} + + + ))} + {pendingInvitations.map((invitation) => ( + + + + {invitation.inviteeEmail.split("@")[0]} + + + {invitation.inviteeEmail} + + + + {t("team.members.pending", "PENDING")} + + + ))} + + )} + + + + ); +}; + +export default function TeamSlide(): SlideConfig { + return { + key: "team", + title: , + body: , + background: TEAM_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx new file mode 100644 index 000000000..4c032fbfd --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/slides/UsageSnapshotSlide.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import { + FreeMeterPanel, + useFreeSnapshot, +} from "@app/components/shared/config/configSections/PaygFree"; +import i18n from "@app/i18n"; +import styles from "@app/components/onboarding/slides/SaasOnboardingSlides.module.css"; + +const USAGE_BACKGROUND = createLightSlideBackground([249, 115, 22], "#FFEDD5"); + +const UsageSnapshotBody = () => { + const { t } = useTranslation(); + const snap = useFreeSnapshot(); + + return ( + + {t( + "onboarding.saas.usage.body", + "Automations, AI and API requests draw from your one-time free allowance. Here's where you stand - manual editing never counts against it.", + )} + {/* .payg provides the CSS variables the meter styles are scoped to */} + + + + + ); +}; + +export default function UsageSnapshotSlide(): SlideConfig { + return { + key: "usage-snapshot", + title: i18n.t( + "onboarding.saas.usage.title", + "Your free Processor allowance", + ), + body: , + background: USAGE_BACKGROUND, + }; +} diff --git a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts index e0728556f..7ef384e16 100644 --- a/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/saas/components/onboarding/useSaasOnboardingState.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useAuth } from "@app/auth/UseSession"; import { useOs } from "@app/hooks/useOs"; +import { useWallet } from "@app/hooks/useWallet"; +import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; import { SLIDE_DEFINITIONS, type ButtonAction, @@ -28,7 +30,9 @@ export function useSaasOnboardingState({ opened, onClose, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { - const { trialStatus, isPro, loading } = useAuth(); + const { loading } = useAuth(); + const { wallet } = useWallet(); + const { isTeamLeader } = useSaaSTeam(); const osType = useOs(); const selectedDownloadUrlRef = useRef(""); @@ -70,13 +74,15 @@ export function useSaasOnboardingState({ selectedDownloadUrlRef.current = url; }, []); - // Resolve flow based on trial status - const resolvedFlow = useMemo( - () => resolveSaasFlow(trialStatus, isPro), - [trialStatus, isPro], - ); + // Usage meter only makes sense for free-tier wallets with allowance left; + // the team slide is for leaders (anonymous guests are never leaders). + const showUsageSlide = wallet?.status === "free" && wallet.freeRemaining > 0; + const showTeamSlide = isTeamLeader; - const flowSlideIds = resolvedFlow.ids; + const flowSlideIds = useMemo( + () => resolveSaasFlow({ showUsageSlide, showTeamSlide }), + [showUsageSlide, showTeamSlide], + ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); @@ -99,16 +105,8 @@ export function useSaasOnboardingState({ osUrl: os.url, osOptions, onDownloadUrlChange: handleDownloadUrlChange, - trialStatus: trialStatus ?? undefined, }); - }, [ - slideDefinition, - os.label, - os.url, - osOptions, - handleDownloadUrlChange, - trialStatus, - ]); + }, [slideDefinition, os.label, os.url, osOptions, handleDownloadUrlChange]); // Navigation functions const goNext = useCallback(() => { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx index 164ae9caa..7c5f49945 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx @@ -209,9 +209,7 @@ function UsageHero({ wallet }: { wallet: Wallet }) { const limit = wallet.billableLimit; const hasLimit = limit != null && limit > 0; - const pct = hasLimit - ? Math.min(100, (wallet.billableUsed / limit) * 100) - : 0; + const pct = hasLimit ? Math.min(100, (wallet.billableUsed / limit) * 100) : 0; const state = hasLimit ? pct >= 100 ? "DEGRADED" @@ -387,7 +385,13 @@ function CapEditor({ ); } -function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) { +function CapReadOnly({ + capUsd, + noCap, +}: { + capUsd: number | null; + noCap: boolean; +}) { const { t } = useTranslation(); const hasCap = !noCap && capUsd != null; return ( @@ -567,10 +571,7 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
{recent.length === 0 ? ( - {t( - "payg.activity.empty", - "No billable activity yet this period.", - )} + {t("payg.activity.empty", "No billable activity yet this period.")} ) : (
@@ -603,7 +604,11 @@ function ActivityFeed({ recent }: { recent: Wallet["recent"] }) { // ─── Stripe CTA ────────────────────────────────────────────────────────────── -function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise }) { +function StripePortalLink({ + onOpenPortal, +}: { + onOpenPortal: () => Promise; +}) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -662,7 +667,12 @@ function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise // ─── Main component ─────────────────────────────────────────────────────── -const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) => { +const Payg: React.FC = ({ + role, + wallet, + onSaveCap, + onOpenPortal, +}) => { useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember"); const { t } = useTranslation(); const isLeader = role === "LEADER"; @@ -681,10 +691,14 @@ const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) =>
- {t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", { - start: fmt(wallet.billingPeriodStart), - end: fmt(wallet.billingPeriodEnd), - })} + {t( + "payg.header.eyebrow", + "Processor plan · {{start}} – {{end}}", + { + start: fmt(wallet.billingPeriodStart), + end: fmt(wallet.billingPeriodEnd), + }, + )} {isLeader @@ -715,7 +729,10 @@ const Payg: React.FC = ({ role, wallet, onSaveCap, onOpenPortal }) =>
- + {t("payg.header.meterLabel", "Metered")}

diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css index 20534849d..17ef96e8b 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css @@ -227,7 +227,9 @@ font-size: 0.95rem; font-family: inherit; cursor: pointer; - transition: transform 120ms ease, box-shadow 120ms ease; + transition: + transform 120ms ease, + box-shadow 120ms ease; white-space: nowrap; } .paygf-cta__button:hover { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx index 27d77be48..ced4f4c2b 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -43,7 +43,7 @@ import { DocHelp } from "./Payg"; // ─── Shared free-tier snapshot ──────────────────────────── -interface FreeSnapshot { +export interface FreeSnapshot { /** One-time free documents used so far (grant − remaining). */ billableUsed: number; /** @@ -61,7 +61,7 @@ interface FreeSnapshot { * numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked * into the rendered UI and made the page look like nothing was wired up. */ -function useFreeSnapshot(): FreeSnapshot { +export function useFreeSnapshot(): FreeSnapshot { const { wallet } = useWallet(); return useMemo(() => { if (wallet) { @@ -80,7 +80,10 @@ function useFreeSnapshot(): FreeSnapshot { type MeterState = "FULL" | "WARNED" | "DEGRADED"; /** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */ -function meterState(used: number, limit: number): { state: MeterState; pct: number } { +function meterState( + used: number, + limit: number, +): { state: MeterState; pct: number } { const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100; const state: MeterState = pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL"; @@ -113,7 +116,10 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) { /> {t("payg.free.editor.eyebrow", "Editor plan · Always free")} - + {pill}

@@ -132,7 +138,7 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) { // ─── Compact one-time free meter (right column of the Processor card) ────── -function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { +export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { const { t } = useTranslation(); const { state, pct } = meterState(snap.billableUsed, snap.billableLimit); const stateLabel = @@ -174,7 +180,9 @@ function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { {t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
- {t("payg.free.hero.neverResets", "One-time, never resets")} + + {t("payg.free.hero.neverResets", "One-time — never resets")} +
); @@ -240,8 +248,10 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
  • - {t("payg.free.cta.benefit3Title", "API access")} - {": "} + + {t("payg.free.cta.benefit3Title", "API access")} + + {" — "} {t( "payg.free.cta.benefit3Body", "call any Stirling endpoint programmatically", diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx index eaeccbc72..ff34d9579 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx @@ -59,7 +59,10 @@ const Plan: React.FC = () => { if (error || !wallet) { return ( - + {error ?? t( "payg.error.body", diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css index d4387e313..90083fb80 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css @@ -53,7 +53,10 @@ font-weight: 600; cursor: pointer; font-variant-numeric: tabular-nums; - transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease; + transition: + color 0.12s ease, + border-color 0.12s ease, + background 0.12s ease; } .scc-chip:hover:not(:disabled) { color: var(--text-primary); @@ -82,7 +85,9 @@ border: 1px dashed var(--scc-chip-border); background: transparent; cursor: text; - transition: border-color 0.12s ease, background 0.12s ease; + transition: + border-color 0.12s ease, + background 0.12s ease; } .scc-custom:hover { border-color: var(--border-strong); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx index fea248ca0..bee9d0474 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx @@ -62,7 +62,10 @@ export interface SpendCapControlProps { } /** Format minor units of an ISO currency ("$2.24", "£0.40"). */ -function formatMinor(minor: number, currency: string | null | undefined): string { +function formatMinor( + minor: number, + currency: string | null | undefined, +): string { const code = (currency ?? "usd").toUpperCase(); try { return new Intl.NumberFormat(undefined, { @@ -212,7 +215,10 @@ const SpendCapControl: React.FC = ({ {previewDocs != null && (
    - +
    {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { diff --git a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx index 4df290dde..24ad959e7 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -148,8 +148,7 @@ const StripeCheckoutPanel: React.FC = ({ const tRef = useRef(t); tRef.current = t; - const publishableKey = - import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; + const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? ""; // Dev preview route has no backend — skip the API call and go straight // to the mock placeholder so the design + completion path stay testable. @@ -214,7 +213,9 @@ const StripeCheckoutPanel: React.FC = ({ } if (cancelled) return; setClientSecret(data.client_secret); - setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_")); + setIsMock( + Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"), + ); } catch (e: unknown) { if (cancelled) return; const msg = diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css index 5840bd8df..2e7aafef5 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css @@ -112,7 +112,9 @@ display: inline-flex; align-items: center; justify-content: center; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .upm-header__close:hover { background: var(--upm-divider); @@ -141,7 +143,9 @@ justify-content: center; flex-shrink: 0; margin-left: -6px; - transition: background 120ms ease, color 120ms ease; + transition: + background 120ms ease, + color 120ms ease; } .upm-header__back:hover { background: var(--upm-divider); @@ -177,7 +181,10 @@ justify-content: center; font-size: 0.7rem; font-weight: 700; - transition: background 200ms ease, border-color 200ms ease, color 200ms ease; + transition: + background 200ms ease, + border-color 200ms ease, + color 200ms ease; } .upm-step[data-state="active"] { color: var(--upm-text); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx index ec9f58723..42139f109 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx @@ -161,7 +161,10 @@ export default function UpgradeModal({

    {step === "confirm" ? t("payg.upgrade.title.confirm", "You're subscribed") - : t("payg.upgrade.title.default", "Upgrade to Processor plan")} + : t( + "payg.upgrade.title.default", + "Upgrade to Processor plan", + )}

    @@ -342,10 +342,7 @@ function CapStep({
  • - {t( - "payg.upgrade.cap.title", - "Set your monthly spend ceiling", - )} + {t("payg.upgrade.cap.title", "Set your monthly spend ceiling")}

    {t( @@ -377,18 +374,12 @@ function CapStep({

    - {t( - "payg.upgrade.help.title", - "What we count toward billing", - )} + {t("payg.upgrade.help.title", "What we count toward billing")}