From 3c934570215c6718b043b35fdb821f94118e433f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:40:27 +0100 Subject: [PATCH 001/109] Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) --- .../api/RearrangePagesPDFController.java | 16 ++++++++-- .../api/RearrangePagesPDFControllerTest.java | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java index 6dd7aacd79..12e3a15b89 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/RearrangePagesPDFController.java @@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; +import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageTree; @@ -261,10 +264,19 @@ public class RearrangePagesPDFController { log.info("newPageOrder = {}", newPageOrder); log.info("totalPages = {}", totalPages); - // Snapshot the desired pages before mutating the source document's page tree. + // Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE) + // so each slot is a distinct node, not one PDPage under multiple /Kids. List newPages = new ArrayList<>(newPageOrder.size()); + Set seenIndices = new HashSet<>(); for (Integer idx : newPageOrder) { - newPages.add(document.getPage(idx)); + PDPage page = document.getPage(idx); + if (!seenIndices.add(idx)) { + // Duplicate index: distinct page node sharing content/resources. + COSDictionary clonedDict = new COSDictionary(); + clonedDict.addAll(page.getCOSObject()); + page = new PDPage(clonedDict); + } + newPages.add(page); } // Rearrange in-place on the source document rather than copying pages into a diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java index a225c3fc52..14b1d9892a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java @@ -9,6 +9,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import org.apache.pdfbox.Loader; @@ -302,6 +303,11 @@ class RearrangePagesPDFControllerTest { assertNotNull(response); // 2 pages * 3 duplicates = 6 final pages assertEquals(6, realDoc.getNumberOfPages()); + // Each duplicate must be a distinct page node in the saved output; a shared + // node under multiple /Kids is an invalid tree readers reject as cyclic. + List savedPages = reloadAndSnapshot(response); + assertEquals(6, savedPages.size()); + assertEquals(6, new HashSet<>(savedPages).size()); } } @@ -323,4 +329,29 @@ class RearrangePagesPDFControllerTest { assertEquals(4, realDoc.getNumberOfPages()); } } + + @Test + void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes() + throws IOException { + MockMultipartFile file = createMockPdf(); + RearrangePagesRequest request = new RearrangePagesRequest(); + request.setFileInput(file); + request.setPageNumbers(""); + request.setCustomMode("SIDE_STITCH_BOOKLET_SORT"); + + // 6 pages is not a multiple of 4, so booklet padding repeats the last page index + // several times; each repeat must be a distinct page node, not one shared node. + try (PDDocument realDoc = buildRealPdf(6)) { + when(pdfDocumentFactory.load(file)).thenReturn(realDoc); + + ResponseEntity response = controller.rearrangePages(request); + + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertEquals(8, realDoc.getNumberOfPages()); + List savedPages = reloadAndSnapshot(response); + assertEquals(8, savedPages.size()); + assertEquals(8, new HashSet<>(savedPages).size()); + } + } } From 8e4b2e2fc685380c2366308295a5820439e5f2a9 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:55:07 +0100 Subject: [PATCH 002/109] Disable update check and notification in SaaS mode (#6863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes In SaaS mode the self-hosted "Update Available" notification could still appear and the update-check code (external call to `supabase.stirling.com/functions/v1/updates`) still ran, even though the cloud owns app versioning. The web `UpdateStartupPopup` was already SaaS-gated via a null override, but two other paths were not: - **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()` ran its startup check and rendered the `UpdateModal` regardless of connection mode, so a self-hosted update popup appeared while connected to SaaS. - **Settings → General** - the core `GeneralSection` fired `checkForUpdate()` on mount unconditionally, even when the update section was hidden (as SaaS does), so the external call still ran. **What changed** - `useDesktopUpdatePopup.ts` - the startup timer now bails out immediately when `connectionModeService.getCurrentMode() === "saas"`. No mode lookup, no external fetch, no modal. - `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns early when `hideUpdateSection` is set, so hiding the section (web SaaS, managed-disabled desktop) also stops the external call. - `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when `useSaaSMode()` is true, which (via the above) suppresses the settings check in desktop-SaaS too. **Why** - in SaaS the update check should never be called and no update notification should be shown; the cloud handles versioning. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../config/configSections/GeneralSection.tsx | 6 +++-- .../config/configSections/GeneralSection.tsx | 7 ++++- .../hooks/useDesktopUpdatePopup.test.ts | 26 ++++++++++++++++++- .../desktop/hooks/useDesktopUpdatePopup.ts | 5 ++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx index e5fdacea79..598b705347 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -112,12 +112,14 @@ const GeneralSection: React.FC = ({ // falling back to the backend version const currentVersion = appVersion ?? config?.appVersion ?? null; - // Check for updates on mount + // Check for updates on mount — skipped when the update UI is hidden (SaaS + // build, managed-disabled desktop) so no external update call ever fires. useEffect(() => { + if (hideUpdateSection) return; if (currentVersion) { checkForUpdate(); } - }, [currentVersion, config?.machineType]); + }, [currentVersion, config?.machineType, hideUpdateSection]); const checkForUpdate = async () => { if (!currentVersion) return; diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/GeneralSection.tsx index abe37d10b8..98c4455ce0 100644 --- a/frontend/editor/src/desktop/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configSections/GeneralSection.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection"; import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings"; import { useDesktopInstall } from "@app/hooks/useDesktopInstall"; +import { useSaaSMode } from "@app/hooks/useSaaSMode"; import { desktopUpdateService, type UpdateMode, @@ -22,6 +23,9 @@ import { const GeneralSection: React.FC = () => { const { t } = useTranslation(); const install = useDesktopInstall(); + // In SaaS connection mode the cloud owns app versioning — hide the update + // section (which also stops the core auto-check from firing). + const isSaaSMode = useSaaSMode(); const [updateModeInfo, setUpdateModeInfo] = useState({ mode: "prompt", locked: false, @@ -93,7 +97,8 @@ const GeneralSection: React.FC = () => { )} ({ invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args), @@ -36,6 +37,12 @@ vi.mock("@app/services/desktopUpdateService", () => ({ }, })); +vi.mock("@app/services/connectionModeService", () => ({ + connectionModeService: { + getCurrentMode: () => getCurrentModeMock(), + }, +})); + import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup"; /** Flush pending microtasks so awaited promises settle. */ @@ -74,8 +81,11 @@ describe("useDesktopUpdatePopup — auto mode", () => { getUpdateModeMock.mockReset(); canInstallUpdatesMock.mockReset(); getUpdateSummaryMock.mockReset(); + getCurrentModeMock.mockReset(); - // Defaults: auto mode, update available, install permitted. + // Defaults: local (non-SaaS) connection, auto mode, update available, + // install permitted. + getCurrentModeMock.mockResolvedValue("local"); getUpdateModeMock.mockResolvedValue("auto"); getVersionMock.mockResolvedValue("1.0.0"); getUpdateSummaryMock.mockResolvedValue({ latest_version: "2.0.0" }); @@ -189,4 +199,18 @@ describe("useDesktopUpdatePopup — auto mode", () => { expect(invocations).toContain("download_and_install_update"); expect(invocations).toContain("restart_app"); }); + + it("skips the update check entirely in SaaS connection mode", async () => { + // In SaaS mode the cloud owns versioning — the self-hosted update check + // must never run: no mode lookup, no external summary fetch, no install. + getCurrentModeMock.mockResolvedValue("saas"); + + await runStartup(); + + expect(getUpdateModeMock).not.toHaveBeenCalled(); + expect(getUpdateSummaryMock).not.toHaveBeenCalled(); + const invocations = invokeMock.mock.calls.map((c) => c[0]); + expect(invocations).not.toContain("download_and_install_update"); + expect(invocations).not.toContain("restart_app"); + }); }); diff --git a/frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.ts b/frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.ts index 6a2df77914..0cd0fcd003 100644 --- a/frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.ts +++ b/frontend/editor/src/desktop/hooks/useDesktopUpdatePopup.ts @@ -6,6 +6,7 @@ import { desktopUpdateService, type CanInstallResult, } from "@app/services/desktopUpdateService"; +import { connectionModeService } from "@app/services/connectionModeService"; const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil"; const STARTUP_DELAY_MS = 15_000; @@ -72,6 +73,10 @@ export function useDesktopUpdatePopup() { hasChecked.current = true; const timer = setTimeout(async () => { + // In SaaS connection mode the cloud owns app versioning — the self-hosted + // update check + popup must never run (no external call, no modal). + if ((await connectionModeService.getCurrentMode()) === "saas") return; + let mode: Awaited> = "prompt"; try { From 67a0ca6110c11ed58d5912a6d7b8068761cf57e3 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 6 Jul 2026 11:21:09 +0200 Subject: [PATCH 003/109] fix(frontend): respect analytics config before initializing PostHog (#6812) # Description of Changes Please provide a summary of the changes, including: - What was changed - Moved PostHog startup out of `index.tsx` and into a config-aware initializer inside `AppProviders`. - Added a dedicated `usePosthogTracking` hook that only initializes PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog` is not disabled. - Kept cookie-consent handling in the same flow so consent is applied only after PostHog is actually initialized. - Removed the unconditional `PostHogProvider` and `posthog.init(...)` bootstrap from the app entrypoint. - Added targeted frontend tests covering analytics-disabled and analytics-enabled startup behavior. - Why the change was made - The previous frontend bootstrap initialized PostHog before app config was loaded, so disabling analytics in the UI or via environment settings did not prevent PostHog network activity. - This change makes analytics behavior follow the server-provided config instead of always connecting on page load. Closes #6358 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../src/core/components/AppProviders.tsx | 7 ++ .../core/hooks/usePosthogTracking.test.tsx | 76 +++++++++++++++++ .../src/core/hooks/usePosthogTracking.ts | 83 +++++++++++++++++++ frontend/editor/src/index.tsx | 37 +-------- 4 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 frontend/editor/src/core/hooks/usePosthogTracking.test.tsx create mode 100644 frontend/editor/src/core/hooks/usePosthogTracking.ts diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index ede9ed266a..f050ae7f9b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; @@ -43,6 +44,11 @@ function ScarfTrackingInitializer() { return null; } +function PosthogTrackingInitializer() { + usePosthogTracking(); + return null; +} + // Component to run app-level initialization (must be inside AppProviders for context access) function AppInitializer() { useAppInitialization(); @@ -122,6 +128,7 @@ export function AppProviders({ retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > + diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx new file mode 100644 index 0000000000..41f5fad7dc --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +const posthogState = vi.hoisted(() => ({ loaded: false })); +const posthogMock = vi.hoisted(() => ({ + get __loaded() { + return posthogState.loaded; + }, + init: vi.fn(() => { + posthogState.loaded = true; + }), + opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), + set_config: vi.fn(), + has_opted_in_capturing: vi.fn(() => false), +})); + +vi.mock("posthog-js", () => ({ + default: posthogMock, +})); + +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; + +describe("usePosthogTracking", () => { + beforeEach(() => { + posthogState.loaded = false; + posthogMock.init.mockClear(); + posthogMock.opt_out_capturing.mockClear(); + posthogMock.opt_in_capturing.mockClear(); + posthogMock.set_config.mockClear(); + vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key"); + vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not initialize PostHog when analytics is disabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).not.toHaveBeenCalled(); + }); + }); + + it("initializes PostHog when analytics is enabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts new file mode 100644 index 0000000000..a8037ec4a7 --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +function applyPosthogConsent(): void { + if (typeof window === "undefined" || !posthog.__loaded) { + return; + } + + const optedIn = + window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; + + if (optedIn) { + posthog.set_config({ persistence: "localStorage+cookie" }); + posthog.opt_in_capturing(); + return; + } + + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); +} + +function ensurePosthogInitialized(): boolean { + if (typeof window === "undefined") { + return false; + } + + const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; + const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!posthogKey || !posthogHost) { + return false; + } + + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: posthogHost, + defaults: "2025-05-24", + capture_exceptions: true, + debug: false, + opt_out_capturing_by_default: true, + persistence: "memory", + cross_subdomain_cookie: false, + }); + } + + return true; +} + +export function usePosthogTracking(): void { + const { config } = useAppConfig(); + + useEffect(() => { + const analyticsEnabled = config?.enableAnalytics === true; + const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false; + + if (!posthogEnabled) { + if (posthog.__loaded) { + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); + } + return; + } + + if (!ensurePosthogInitialized()) { + return; + } + + applyPosthogConsent(); + + const handleConsentChange = () => { + applyPosthogConsent(); + }; + + window.addEventListener("cc:onConsent", handleConsentChange); + window.addEventListener("cc:onChange", handleConsentChange); + + return () => { + window.removeEventListener("cc:onConsent", handleConsentChange); + window.removeEventListener("cc:onChange", handleConsentChange); + }; + }, [config?.enableAnalytics, config?.enablePosthog]); +} diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index 017138cebc..d776918bd1 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -13,8 +13,6 @@ import { ColorSchemeScript } from "@mantine/core"; import { BrowserRouter } from "react-router-dom"; import App from "@app/App"; import "@app/i18n"; // Initialize i18next -import posthog from "posthog-js"; -import { PostHogProvider } from "@posthog/react"; import { BASE_PATH } from "@app/constants/app"; import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; @@ -35,33 +33,6 @@ if (typeof window !== "undefined") { } } -posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { - api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: "2025-05-24", - capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this - debug: false, - opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent - persistence: "memory", // No cookies/localStorage written until user opts in - cross_subdomain_cookie: false, -}); - -function updatePosthogConsent() { - if (!posthog.__loaded) return; - const optIn = - window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; - if (optIn) { - posthog.set_config({ persistence: "localStorage+cookie" }); - posthog.opt_in_capturing(); - } else { - posthog.opt_out_capturing(); - posthog.set_config({ persistence: "memory" }); - } - console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out"); -} - -window.addEventListener("cc:onConsent", updatePosthogConsent); -window.addEventListener("cc:onChange", updatePosthogConsent); - const container = document.getElementById("root"); if (!container) { throw new Error("Root container missing in index.html"); @@ -71,10 +42,8 @@ const root = ReactDOM.createRoot(container); // Finds the root DOM element root.render( - - - - - + + + , ); From 1b7ffcdbac721b754414d8a7ee390327d2058a9e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 6 Jul 2026 10:22:41 +0100 Subject: [PATCH 004/109] Fix tooltip positioning on Add Page Numbers (#6885) # Description of Changes ## Before image ## After image --- .../addPageNumbers/AddPageNumbersAppearanceSettings.tsx | 5 +++++ .../tools/addPageNumbers/AddPageNumbersPositionSettings.tsx | 2 ++ 2 files changed, 7 insertions(+) diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx index 627f7c1b9e..11ca767bca 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -27,6 +27,7 @@ const AddPageNumbersAppearanceSettings = ({ return ( 001). Set 0 to disable.", @@ -90,6 +93,7 @@ const AddPageNumbersAppearanceSettings = ({ Date: Tue, 7 Jul 2026 10:37:35 +0100 Subject: [PATCH 005/109] Set App version to v2.14.1 (#6891) Upped version in build.gradle then ran build so version falls through --- .github/aur/stirling-pdf-desktop/PKGBUILD | 2 +- .github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +- build.gradle | 2 +- frontend/editor/src-tauri/tauri.conf.json | 2 +- frontend/editor/src/core/testing/serverExperienceSimulations.ts | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index c02bde345e..5d92425b19 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.0 +pkgver=2.14.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index c73b1c5087..f5a2bf3c6c 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.0 +pkgver=2.14.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/build.gradle b/build.gradle index 3946b32449..5c89fd7af2 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.0' + version = '2.14.1' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 87f3a48a1d..6cfe33cbe6 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.0", + "version": "2.14.1", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index 09344f9234..cd7ed8f2ba 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.0", + appVersion: "2.14.1", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index 50bf5eb230..1aaabfeb33 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.0", + appVersion: "2.14.1", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, From 8535c7e9aced4e050d3f660edd13319eba1a06a4 Mon Sep 17 00:00:00 2001 From: Ludy Date: Tue, 7 Jul 2026 22:57:34 +0200 Subject: [PATCH 006/109] feat(ui): add dedicated third-party license sections to settings (#6820) --- .../public/locales/en-US/translation.toml | 15 + frontend/editor/public/og-metadata.json | 12 + .../editor/src/assets/3rdPartyLicenses.json | 347 ++++++++++++++---- .../shared/config/configNavSections.tsx | 16 + .../ThirdPartyLicensesSection.tsx | 235 ++++++++++++ .../core/components/shared/config/types.ts | 2 + 6 files changed, 555 insertions(+), 72 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 7f81e56f3b..bd5b20bcf6 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6934,6 +6934,21 @@ manage = "Manage" description = "Policies and legal information for this service." title = "Legal Documents" +[settings.licenses] +backendDescription = "Licenses for backend dependencies bundled with this server." +backendLabel = "Backend Licenses" +backendTitle = "Backend 3rd Party Licenses" +empty = "No dependencies found." +frontendDescription = "Licenses for frontend dependencies bundled into the release build." +frontendLabel = "Frontend Licenses" +frontendTitle = "Frontend 3rd Party Licenses" +license = "License" +listDescription = "The list is shown directly in the UI from the release bundle or backend endpoint." +listTitle = "Bundled dependencies" +loadError = "Failed to load third-party licenses" +module = "Module" +version = "Version" + [settings.licensingAnalytics] audit = "Audit" plan = "Plan" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index e8aded59d2..65f1da789c 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -485,6 +485,16 @@ "title": "Legal Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" }, + "/settings/backendThirdPartyLicenses": { + "image": "/og_images/home.png", + "title": "Backend Third Party Licenses Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, + "/settings/frontendThirdPartyLicenses": { + "image": "/og_images/home.png", + "title": "Frontend Third Party Licenses Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, "/settings/payg": { "image": "/og_images/home.png", "title": "Payg Settings - Stirling PDF", @@ -641,6 +651,8 @@ "/settings/adminMcp": "/settings/adminMcp", "/settings/help": "/settings/help", "/settings/legal": "/settings/legal", + "/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses", + "/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses", "/settings/payg": "/settings/payg" } } diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index baf60879b6..3b606376de 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -3,133 +3,182 @@ { "moduleName": "@atlaskit/pragmatic-drag-and-drop", "moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git", - "moduleVersion": "1.7.7", + "moduleVersion": "1.7.9", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git" }, { - "moduleName": "@embedpdf/core", - "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz", - "moduleVersion": "1.3.0", + "moduleName": "@cantoo/pdf-lib", + "moduleUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git", + "moduleVersion": "2.6.5", "moduleLicense": "MIT", - "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz" + "moduleLicenseUrl": "git+https://github.com/cantoo-scribe/pdf-lib.git" + }, + { + "moduleName": "@dnd-kit/core", + "moduleUrl": "git+https://github.com/clauderic/dnd-kit.git", + "moduleVersion": "6.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/clauderic/dnd-kit.git" + }, + { + "moduleName": "@embedpdf/core", + "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-2.14.4.tgz" }, { "moduleName": "@embedpdf/engines", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/models", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-annotation", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-attachment", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-bookmark", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-document-manager", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-export", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-history", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-interaction-manager", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-loader", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-pan", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-print", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-redaction", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-render", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-rotate", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-scroll", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-search", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-selection", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-spread", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-thumbnail", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-tiling", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-viewport", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, { "moduleName": "@embedpdf/plugin-zoom", "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", + "moduleVersion": "2.14.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" }, @@ -157,94 +206,185 @@ { "moduleName": "@mantine/core", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/dates", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/dropzone", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mantine/hooks", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", + "moduleVersion": "8.3.18", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" }, { "moduleName": "@mui/icons-material", "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", + "moduleVersion": "9.0.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" }, { "moduleName": "@mui/material", "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", + "moduleVersion": "9.0.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" }, { - "moduleName": "@tailwindcss/postcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", + "moduleName": "@posthog/react", + "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", + "moduleVersion": "1.8.2", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" + }, + { + "moduleName": "@reactour/tour", + "moduleUrl": "git+https://github.com/elrumordelaluz/reactour.git", + "moduleVersion": "3.8.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/elrumordelaluz/reactour.git" + }, + { + "moduleName": "@stripe/react-stripe-js", + "moduleUrl": "https://github.com/stripe/react-stripe-js.git", + "moduleVersion": "4.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/stripe/react-stripe-js.git" + }, + { + "moduleName": "@stripe/stripe-js", + "moduleUrl": "https://github.com/stripe/stripe-js.git", + "moduleVersion": "7.9.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/stripe/stripe-js.git" + }, + { + "moduleName": "@supabase/supabase-js", + "moduleUrl": "https://github.com/supabase/supabase-js.git", + "moduleVersion": "2.100.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/supabase/supabase-js.git" + }, + { + "moduleName": "@tailwindcss/postcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.2.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git" }, { "moduleName": "@tanstack/react-virtual", "moduleUrl": "git+https://github.com/TanStack/virtual.git", - "moduleVersion": "3.13.12", + "moduleVersion": "3.13.23", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git" }, + { + "moduleName": "@tauri-apps/api", + "moduleUrl": "git+https://github.com/tauri-apps/tauri.git", + "moduleVersion": "2.10.1", + "moduleLicense": "Apache-2.0 OR MIT", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/tauri.git" + }, + { + "moduleName": "@tauri-apps/plugin-dialog", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.7.0", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-fs", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.5.0", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-http", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.5.7", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-notification", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.3.3", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@tauri-apps/plugin-shell", + "moduleUrl": "git+https://github.com/tauri-apps/plugins-workspace.git", + "moduleVersion": "2.3.5", + "moduleLicense": "MIT OR Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/tauri-apps/plugins-workspace.git" + }, + { + "moduleName": "@userback/widget", + "moduleUrl": "git+https://github.com/userback/widget-js.git", + "moduleVersion": "0.3.12", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/userback/widget-js.git" + }, { "moduleName": "autoprefixer", "moduleUrl": "git+https://github.com/postcss/autoprefixer.git", - "moduleVersion": "10.4.21", + "moduleVersion": "10.4.27", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git" }, { "moduleName": "axios", "moduleUrl": "git+https://github.com/axios/axios.git", - "moduleVersion": "1.12.2", + "moduleVersion": "1.15.0", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/axios/axios.git" }, + { + "moduleName": "d3", + "moduleUrl": "git+https://github.com/d3/d3.git", + "moduleVersion": "7.9.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/d3/d3.git" + }, + { + "moduleName": "globals", + "moduleUrl": "git+https://github.com/sindresorhus/globals.git", + "moduleVersion": "17.5.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/sindresorhus/globals.git" + }, { "moduleName": "i18next", "moduleUrl": "git+https://github.com/i18next/i18next.git", - "moduleVersion": "25.5.2", + "moduleVersion": "25.10.10", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/i18next.git" }, { "moduleName": "i18next-browser-languagedetector", "moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git", - "moduleVersion": "8.2.0", + "moduleVersion": "8.2.1", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git" }, - { - "moduleName": "i18next-http-backend", - "moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git", - "moduleVersion": "3.0.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git" - }, { "moduleName": "jszip", "moduleUrl": "git+https://github.com/Stuk/jszip.git", @@ -254,66 +394,129 @@ }, { "moduleName": "license-report", - "moduleUrl": "git+https://github.com/kessler/license-report.git", - "moduleVersion": "6.8.0", + "moduleUrl": "git+https://github.com/bepo65/license-report.git", + "moduleVersion": "6.8.2", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/kessler/license-report.git" - }, - { - "moduleName": "pdf-lib", - "moduleUrl": "git+https://github.com/Hopding/pdf-lib.git", - "moduleVersion": "1.17.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git" + "moduleLicenseUrl": "git+https://github.com/bepo65/license-report.git" }, { "moduleName": "pdfjs-dist", "moduleUrl": "git+https://github.com/mozilla/pdf.js.git", - "moduleVersion": "5.4.149", + "moduleVersion": "5.5.207", "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git" }, + { + "moduleName": "peerjs", + "moduleUrl": "git+https://github.com/peers/peerjs.git", + "moduleVersion": "1.5.5", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/peers/peerjs.git" + }, + { + "moduleName": "pixelmatch", + "moduleUrl": "git+https://github.com/mapbox/pixelmatch.git", + "moduleVersion": "7.1.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/mapbox/pixelmatch.git" + }, { "moduleName": "posthog-js", - "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", - "moduleVersion": "1.268.0", + "moduleUrl": "https://github.com/PostHog/posthog-js", + "moduleVersion": "1.363.3", "moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE", - "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" + "moduleLicenseUrl": "https://github.com/PostHog/posthog-js" + }, + { + "moduleName": "qrcode.react", + "moduleUrl": "git+https://github.com/zpao/qrcode.react.git", + "moduleVersion": "4.2.0", + "moduleLicense": "ISC", + "moduleLicenseUrl": "git+https://github.com/zpao/qrcode.react.git" }, { "moduleName": "react", "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", + "moduleVersion": "19.2.4", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/facebook/react.git" }, { "moduleName": "react-dom", "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", + "moduleVersion": "19.2.4", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/facebook/react.git" }, + { + "moduleName": "react-easy-crop", + "moduleUrl": "git+https://github.com/ValentinH/react-easy-crop.git", + "moduleVersion": "5.5.6", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/ValentinH/react-easy-crop.git" + }, { "moduleName": "react-i18next", "moduleUrl": "git+https://github.com/i18next/react-i18next.git", - "moduleVersion": "15.7.3", + "moduleVersion": "16.6.6", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git" }, + { + "moduleName": "react-markdown", + "moduleUrl": "git+https://github.com/remarkjs/react-markdown.git", + "moduleVersion": "9.1.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/remarkjs/react-markdown.git" + }, + { + "moduleName": "react-rnd", + "moduleUrl": "git+https://github.com/bokuweb/react-rnd.git", + "moduleVersion": "10.5.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/bokuweb/react-rnd.git" + }, { "moduleName": "react-router-dom", "moduleUrl": "git+https://github.com/remix-run/react-router.git", - "moduleVersion": "7.9.1", + "moduleVersion": "7.13.2", "moduleLicense": "MIT", "moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git" }, { - "moduleName": "tailwindcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", + "moduleName": "recharts", + "moduleUrl": "git+https://github.com/recharts/recharts.git", + "moduleVersion": "3.8.0", "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + "moduleLicenseUrl": "git+https://github.com/recharts/recharts.git" + }, + { + "moduleName": "remark-gfm", + "moduleUrl": "git+https://github.com/remarkjs/remark-gfm.git", + "moduleVersion": "4.0.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/remarkjs/remark-gfm.git" + }, + { + "moduleName": "signature_pad", + "moduleUrl": "git+https://github.com/szimek/signature_pad.git", + "moduleVersion": "5.1.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/szimek/signature_pad.git" + }, + { + "moduleName": "smol-toml", + "moduleUrl": "github:squirrelchat/smol-toml", + "moduleVersion": "1.6.1", + "moduleLicense": "BSD-3-Clause", + "moduleLicenseUrl": "github:squirrelchat/smol-toml" + }, + { + "moduleName": "tailwindcss", + "moduleUrl": "https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.2.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://github.com/tailwindlabs/tailwindcss.git" }, { "moduleName": "web-vitals", diff --git a/frontend/editor/src/core/components/shared/config/configNavSections.tsx b/frontend/editor/src/core/components/shared/config/configNavSections.tsx index 260cbe970a..db02ad8458 100644 --- a/frontend/editor/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/core/components/shared/config/configNavSections.tsx @@ -5,6 +5,10 @@ import HotkeysSection from "@app/components/shared/config/configSections/Hotkeys import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; import HelpSection from "@app/components/shared/config/configSections/HelpSection"; import LegalSection from "@app/components/shared/config/configSections/LegalSection"; +import { + BackendThirdPartyLicensesSection, + FrontendThirdPartyLicensesSection, +} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; export interface ConfigNavItem { key: NavKey; @@ -80,6 +84,18 @@ export const useConfigNavSections = ( icon: "gavel-rounded", component: , }, + { + key: "backendThirdPartyLicenses", + label: t("settings.licenses.backendLabel", "Backend Licenses"), + icon: "article-rounded", + component: , + }, + { + key: "frontendThirdPartyLicenses", + label: t("settings.licenses.frontendLabel", "Frontend Licenses"), + icon: "code-rounded", + component: , + }, ], }, ]; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx new file mode 100644 index 0000000000..5d0de982fc --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Anchor, + Group, + Loader, + Paper, + Stack, + Table, + Text, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import apiClient from "@app/services/apiClient"; +import frontendLicenses from "../../../../../assets/3rdPartyLicenses.json"; // eslint-disable-line no-restricted-imports -- asset lives outside @app alias root + +interface Dependency { + moduleName?: string; + moduleUrl?: string; + moduleVersion?: string; + moduleLicense?: string; + moduleLicenseUrl?: string; +} + +interface LicensesResponse { + dependencies?: Dependency[]; +} + +interface LicensesSectionBodyProps { + title: string; + description: string; + dependencies: Dependency[]; +} + +function LicensesSectionBody({ + title, + description, + dependencies, +}: LicensesSectionBodyProps) { + const { t } = useTranslation(); + const sortedDependencies = useMemo( + () => + [...dependencies].sort((a, b) => + (a.moduleName || "").localeCompare(b.moduleName || ""), + ), + [dependencies], + ); + + const getDependencyKey = (dependency: Dependency) => + [ + dependency.moduleName ?? "module", + dependency.moduleVersion ?? "version", + dependency.moduleUrl ?? "url", + ].join(":"); + + return ( + + + +
+ + {title} + + + {description} + +
+ + +
+ + {t("settings.licenses.listTitle", "Bundled dependencies")} + + + {t( + "settings.licenses.listDescription", + "The list is shown directly in the UI from the release bundle or backend endpoint.", + )} + +
+
+ + + + + {t("settings.licenses.module", "Module")} + {t("settings.licenses.version", "Version")} + {t("settings.licenses.license", "License")} + + + + {sortedDependencies.length === 0 ? ( + + + + {t("settings.licenses.empty", "No dependencies found.")} + + + + ) : ( + sortedDependencies.map((dependency) => ( + + + {dependency.moduleUrl ? ( + + {dependency.moduleName || "-"} + + ) : ( + {dependency.moduleName || "-"} + )} + + + + {dependency.moduleVersion || "-"} + + + + {dependency.moduleLicenseUrl ? ( + + {dependency.moduleLicense || "-"} + + ) : ( + {dependency.moduleLicense || "-"} + )} + + + )) + )} + +
+
+
+
+ ); +} + +export function BackendThirdPartyLicensesSection() { + const { t } = useTranslation(); + const [dependencies, setDependencies] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadLicenses = async () => { + try { + setLoading(true); + setError(null); + const response = await apiClient.get( + "/api/v1/ui-data/licenses", + { suppressErrorToast: true }, + ); + setDependencies(response.data?.dependencies ?? []); + } catch (err: unknown) { + setError( + isAxiosError(err) + ? err.response?.data?.message || err.message + : t( + "settings.licenses.loadError", + "Failed to load third-party licenses", + ), + ); + } finally { + setLoading(false); + } + }; + + void loadLicenses(); + }, [t]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + return ( + + ); +} + +export function FrontendThirdPartyLicensesSection() { + const { t } = useTranslation(); + const dependencies = + (frontendLicenses as LicensesResponse).dependencies ?? []; + + return ( + + ); +} + +export default function ThirdPartyLicensesSection() { + return ; +} diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index a71866d0c1..9fa87fe915 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -32,6 +32,8 @@ export const VALID_NAV_KEYS = [ "adminMcp", "help", "legal", + "backendThirdPartyLicenses", + "frontendThirdPartyLicenses", "payg", ] as const; From 01a1ef8c44448a08f67fa31231175af0df4194ee Mon Sep 17 00:00:00 2001 From: LFdev <146497073+LFd3v@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:58:21 -0300 Subject: [PATCH 007/109] Fix missing app icon on Linux/Wayland (#6875) Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ludy --- frontend/editor/src-tauri/stirling-pdf.desktop | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src-tauri/stirling-pdf.desktop b/frontend/editor/src-tauri/stirling-pdf.desktop index e9e26a39c1..13da1b88c6 100644 --- a/frontend/editor/src-tauri/stirling-pdf.desktop +++ b/frontend/editor/src-tauri/stirling-pdf.desktop @@ -10,7 +10,8 @@ Terminal=false MimeType=application/pdf; Categories=Office;Graphics;Utility; Actions=open-file; +StartupWMClass=Stirling-PDF [Desktop Action open-file] Name=Open PDF File -Exec={{exec}} %F \ No newline at end of file +Exec={{exec}} %F From 5fba2720f0a350fb395f3aa9741c828562aee2b8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:39:57 +0100 Subject: [PATCH 008/109] Fix cert sign not showing under certain instances (#6908) --- .../tests/stubbed/cert-sign-wizard.spec.ts | 24 ++++++++++++++----- frontend/editor/src/core/tools/CertSign.tsx | 22 +++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts index 807e4231d4..17f9070a78 100644 --- a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts @@ -59,7 +59,7 @@ async function mockHardwareEndpoints(page: Page) { } test.describe("CertSign tool - certificate source model", () => { - test("renders, accepts a PDF, and exposes the Upload source", async ({ + test("skips the redundant source step and goes straight to certificate format when Upload is the only source", async ({ page, }) => { await page.route("**/api/v1/security/cert-sign", (route) => @@ -76,10 +76,21 @@ test.describe("CertSign tool - certificate source model", () => { await uploadFiles(page, SAMPLE_PDF); await expect(page).toHaveURL(/\/cert-sign/); - // Source step always offers "Upload" (the former "Manual" mode). + // With no server cert or hardware token there is nothing to choose, so the + // whole "Certificate source" step is hidden and the format picker shows directly. await expect( - page.getByRole("button", { name: /^upload$/i }).first(), - ).toBeAttached({ timeout: 10_000 }); + page.getByRole("button", { name: /pkcs12/i }).first(), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/certificate source/i)).toHaveCount(0); + await expect( + page.getByText(/no other certificate sources are available/i), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: /this device/i }), + ).toHaveCount(0); + await expect(page.getByRole("button", { name: /^server$/i })).toHaveCount( + 0, + ); }); test("does NOT offer 'This device' when not running as desktop", async ({ @@ -89,9 +100,10 @@ test.describe("CertSign tool - certificate source model", () => { await page.waitForLoadState("domcontentloaded"); await uploadFiles(page, SAMPLE_PDF); + // No alternative sources: the source step is hidden, and hardware is never offered. await expect( - page.getByRole("button", { name: /^upload$/i }).first(), - ).toBeAttached({ timeout: 10_000 }); + page.getByRole("button", { name: /pkcs12/i }).first(), + ).toBeVisible({ timeout: 10_000 }); await expect( page.getByRole("button", { name: /this device/i }), ).toHaveCount(0); diff --git a/frontend/editor/src/core/tools/CertSign.tsx b/frontend/editor/src/core/tools/CertSign.tsx index dd0668a491..4f5173b174 100644 --- a/frontend/editor/src/core/tools/CertSign.tsx +++ b/frontend/editor/src/core/tools/CertSign.tsx @@ -1,5 +1,7 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings"; import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings"; import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings"; @@ -23,6 +25,25 @@ const CertSign = (props: BaseToolProps) => { props, ); + const { config } = useAppConfig(); + // "Upload" is always available; the source chooser is only meaningful when a + // server certificate or a hardware token gives the user an actual alternative. + const hasCertSourceChoice = + (config?.serverCertificateEnabled ?? false) || + (config?.hardwareSigningAvailable ?? false); + + // With Upload as the only source, keep signMode on MANUAL even if a saved + // automation set AUTO/DEVICE, so the hidden source step can't strand the flow. + useEffect(() => { + if (!hasCertSourceChoice && base.params.parameters.signMode !== "MANUAL") { + base.params.updateParameter("signMode", "MANUAL"); + } + }, [ + hasCertSourceChoice, + base.params.parameters.signMode, + base.params.updateParameter, + ]); + const certTypeTips = useCertificateTypeTips(); const appearanceTips = useSignatureAppearanceTips(); const signModeTips = useSignModeTips(); @@ -63,6 +84,7 @@ const CertSign = (props: BaseToolProps) => { steps: [ { title: t("certSign.source.stepTitle", "Certificate source"), + isVisible: hasCertSourceChoice, isCollapsed: base.settingsCollapsed, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset From 72729e99c1294e41dda5ddaf9cf72bd975337d89 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:02:38 +0100 Subject: [PATCH 009/109] fix(release): stop msiexec hang in Windows signature verify; don't force latest or regen release notes --- .github/workflows/multiOSReleases.yml | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f5fb743a3f..3a76708460 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -510,6 +510,7 @@ jobs: # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + timeout-minutes: 15 shell: pwsh run: | $allSigned = $true @@ -531,11 +532,26 @@ jobs: # Extract MSI and verify the inner exe (the file that actually gets installed). # This is the critical check - AV flags the installed exe at runtime. + # Use lessmsi, not `msiexec /a`: msiexec serializes on the global + # _MSIExecute mutex and hangs forever on hosted runners when another + # installer is busy. lessmsi reads MSI tables directly - no mutex, no service. $msi = $msiFiles[0].FullName $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } - $proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow - if ($proc.ExitCode -eq 0) { + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + + choco install lessmsi -y --no-progress --limit-output | Out-Null + + # Bound the extraction and kill on hang (defence in depth over timeout-minutes). + $proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow + if (-not $proc.WaitForExit(120000)) { + try { $proc.Kill() } catch {} + Write-Host "[ERROR] MSI extraction timed out after 120s" + $allSigned = $false + } elseif ($proc.ExitCode -ne 0) { + Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" + $allSigned = $false + } else { $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 if ($innerExe) { $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName @@ -548,9 +564,6 @@ jobs: Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" $allSigned = $false } - } else { - Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" - $allSigned = $false } if (-not $allSigned) { @@ -800,7 +813,11 @@ jobs: uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} - generate_release_notes: true + # Don't regenerate/append notes on re-runs, and don't force this into the + # "Latest" slot - leave the release body and latest marker as they are. + generate_release_notes: false + append_body: false + make_latest: false fail_on_unmatched_files: true # Installers + updater payloads + manifest. .sig contents are embedded # in latest.json so the .sig files themselves are not uploaded. From a7307ff393f05733281f9521db12af2fd073dc05 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:50:36 +0100 Subject: [PATCH 010/109] Fix Postgres user settings for some users --- .../java/stirling/software/proprietary/security/model/User.java | 1 - 1 file changed, 1 deletion(-) 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 2b2d22cfc7..eeeda1823b 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 @@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable { @ElementCollection @MapKeyColumn(name = "setting_key") - @Lob @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) @JsonIgnore From 38ccea074cefe98235b1f67a5c4d0eabcc55c523 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:50:37 +0100 Subject: [PATCH 011/109] Version bump --- .github/aur/stirling-pdf-desktop/PKGBUILD | 2 +- .github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +- build.gradle | 2 +- frontend/editor/src-tauri/tauri.conf.json | 2 +- frontend/editor/src/core/testing/serverExperienceSimulations.ts | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index 5d92425b19..fb6a99cfca 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index f5a2bf3c6c..70bcee0423 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/build.gradle b/build.gradle index 5c89fd7af2..073bccc654 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.1' + version = '2.14.2' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 6cfe33cbe6..dee2cc7023 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.1", + "version": "2.14.2", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index cd7ed8f2ba..97da95c730 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.1", + appVersion: "2.14.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index 1aaabfeb33..d92cf8fdf9 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.1", + appVersion: "2.14.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, From b019f9b570a78e20ac4cacece82199d9fe9d0c00 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 13 Jul 2026 13:55:36 +0100 Subject: [PATCH 012/109] Fix missing and broken translations in Processor (#7016) # Description of Changes image Started from trying to fix this, but became a larger piece of work to find missing/broken translations in the Processor and fix as many as I could. --- .../public/locales/en-US/translation.toml | 29 +++++++++++++++++-- frontend/editor/src/portal/api/agents.ts | 5 ++++ frontend/editor/src/portal/api/policies.ts | 19 ++++++++++-- frontend/editor/src/portal/api/users.ts | 23 +++++++++------ .../components/ProcessingStatusStrip.tsx | 2 +- .../agent-builder/AgentBuilderPanel.tsx | 8 +++-- .../agent-builder/AgentSelector.tsx | 8 +++-- .../agent-builder/VersionsPanel.tsx | 8 +++-- .../catalogue/ComponentDetailModal.tsx | 8 +++-- .../components/infrastructure/ApiKeyCard.tsx | 2 +- .../components/procurement/DocumentLedger.tsx | 2 +- .../components/users/PendingInvitations.tsx | 12 ++++---- .../src/portal/contexts/TierContext.tsx | 12 +++++--- .../editor/src/portal/mocks/procurement.ts | 10 +++---- frontend/editor/src/portal/views/Policies.tsx | 4 +-- 15 files changed, 111 insertions(+), 41 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 9663478000..cc65a78ce8 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6240,6 +6240,10 @@ muted = "muted" nameLabel = "Name" namePlaceholder = "e.g. Compliance escalation" +[portal.agentBuilder.status] +draft = "Draft" +published = "Published" + [portal.agentBuilder.tabs] evals = "Evals" scenarios = "Scenarios" @@ -6463,7 +6467,7 @@ install = "Install" usage = "Usage" [portal.catalogue.detail.locked] -description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it." +description = "{{name}} is included from the {{plan}}. Upgrade to embed it." title = "Not available on your plan" [portal.catalogue.detail.preview] @@ -7415,6 +7419,7 @@ manual = "Manual" schedule = "Scheduled" [portal.policies] +defaultName = "{{category}} Policy" subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." title = "Policies" @@ -8125,6 +8130,11 @@ chooseType = "Choose type" configure = "Configure" review = "Review & connect" +[portal.tier] +enterprise = "Enterprise plan" +free = "Editor plan" +pro = "Processor plan" + [portal.usage] managePayment = "Manage Payment" subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." @@ -10139,6 +10149,19 @@ resetPw = "Reset password" suspend = "Suspend" unlock = "Unlock account" +[users.activity] +daysAgo = "{{count}}d ago" +hoursAgo = "{{count}}h ago" +justNow = "Just now" +minutesAgo = "{{count}}m ago" +monthsAgo_one = "{{count}} month ago" +monthsAgo_other = "{{count}} months ago" +never = "Never" +weeksAgo_one = "{{count}} week ago" +weeksAgo_other = "{{count}} weeks ago" +yearsAgo_one = "{{count}} year ago" +yearsAgo_other = "{{count}} years ago" + [users.cap] addProcessor = "+ Processor" approver = "Approves policy" @@ -10209,7 +10232,9 @@ by = "Invited by {{who}}" cancel = "Cancel" count = "{{count}} pending" desc = "Invited people who haven't joined yet. They hold a seat until they accept." -expires = "Expires" +expiresInDays_one = "Expires in {{count}} day" +expiresInDays_other = "Expires in {{count}} days" +expiresToday = "Expires today" title = "Pending invitations" [users.loadError] diff --git a/frontend/editor/src/portal/api/agents.ts b/frontend/editor/src/portal/api/agents.ts index 61a9e75906..7a0cd49464 100644 --- a/frontend/editor/src/portal/api/agents.ts +++ b/frontend/editor/src/portal/api/agents.ts @@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record = { draft: "neutral", }; +export const AGENT_STATUS_LABEL: Record = { + published: "portal.agentBuilder.status.published", + draft: "portal.agentBuilder.status.draft", +}; + /** * Catalogue of tools an agent can be granted or denied. Surfaced as the chip * palette in restricted mode so the deny list is picked from a known set diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 5c3c33a75a..78f8b07667 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -9,6 +9,7 @@ * approach the editor uses for its own catalogue view. */ +import type { TFunction } from "i18next"; import { apiClient } from "@portal/api/http"; import { fromWirePolicy, toWirePolicy } from "@app/policies/codec"; import { runsToActivity, runsToStats } from "@app/policies/runs"; @@ -556,17 +557,30 @@ const DEFAULT_RETRY_DELAY = 5; // POST /api/v1/policies endpoint. The real backend ignores unknown fields. type CatalogueWireBody = WirePolicy & { categoryId: string }; +/** + * The persisted policy name derived from its category, e.g. "Security Policy". + * `category.label` is an i18n key, so translate it before building the name; + * otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources + * "Used by" pill). + */ +function policyDisplayName(entry: CatalogueEntry, t: TFunction): string { + return t("portal.policies.defaultName", { + category: t(entry.category.label), + }); +} + /** Build a wire policy from a setup wizard result. */ export function buildWireFromSetup( entry: CatalogueEntry, result: PolicySetupResult, + t: TFunction, enabled = true, ): CatalogueWireBody { return { categoryId: entry.category.id, ...toWirePolicy({ id: entry.policy?.state.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: result.sources, @@ -589,13 +603,14 @@ export function buildWireFromState( entry: CatalogueEntry, policy: DecoratedPolicy, enabled: boolean, + t: TFunction, ): CatalogueWireBody { const s = policy.state; return { categoryId: entry.category.id, ...toWirePolicy({ id: s.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: s.sources, diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 3505238927..19a686cf8c 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -1,3 +1,7 @@ +// The bare i18next singleton (the same instance @app/i18n initializes at +// startup), imported directly so this data module doesn't pull i18n's +// init side effects into unit tests that mock react-i18next. +import i18n from "i18next"; import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; @@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId { /** A member's last-seen time as plain language; "Never" when no session is tracked. */ function relativeTime(value: number | string | undefined): string { - if (value === undefined || value === null) return "Never"; + if (value === undefined || value === null) + return i18n.t("users.activity.never"); const ts = typeof value === "string" ? Date.parse(value) : value; - if (!Number.isFinite(ts) || ts <= 0) return "Never"; + if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never"); const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins}m ago`; + if (mins < 1) return i18n.t("users.activity.justNow"); + if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins }); const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours }); const days = Math.round(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return i18n.t("users.activity.daysAgo", { count: days }); const weeks = Math.round(days / 7); - if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks }); const months = Math.round(days / 30); - if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`; + if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months }); const years = Math.round(days / 365); - return years <= 1 ? "1 year ago" : `${years} years ago`; + return i18n.t("users.activity.yearsAgo", { count: years }); } /** 0 / huge sentinel license values mean "no seat limit". */ diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx index af198d488f..2f4602b11d 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx @@ -26,7 +26,7 @@ export function ProcessingStatusStrip() { style={{ background: TIER_INFO[tier].dotColor }} aria-hidden /> - {TIER_INFO[tier].label} + {t(TIER_INFO[tier].labelKey)} · diff --git a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx index 20cbcab89c..a411f7f680 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx @@ -1,7 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { StatusBadge, Tabs, type TabItem } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel"; import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel"; import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel"; @@ -52,7 +56,7 @@ export function AgentBuilderPanel({
- {agent.status} + {t(AGENT_STATUS_LABEL[agent.status])} {agent.version} diff --git a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx index 9891c10d03..bb01bf914b 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx @@ -1,5 +1,9 @@ import { useTranslation } from "react-i18next"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { Button, StatusBadge } from "@app/ui"; import "@portal/views/AgentBuilder.css"; @@ -40,7 +44,7 @@ export function AgentSelector({ - {a.status} + {t(AGENT_STATUS_LABEL[a.status])} {a.version} diff --git a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx index 20d71dbfbc..3b63fe3045 100644 --- a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx @@ -1,6 +1,10 @@ import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; interface VersionsPanelProps { @@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {v.version} - {v.status} + {t(AGENT_STATUS_LABEL[v.status])} {isCurrent && ( diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index 337cfc6119..0599d80d72 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -12,9 +12,11 @@ import { } from "@app/ui"; import { type SdkComponent, + BILLING_UNIT_LABEL, MATURITY_META, formatPrice, } from "@portal/api/sdkComponents"; +import { TIER_INFO } from "@portal/contexts/TierContext"; import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable"; import "@portal/views/Components.css"; @@ -113,7 +115,7 @@ export function ComponentDetailModal({ title={t("portal.catalogue.detail.locked.title")} description={t("portal.catalogue.detail.locked.description", { name: component.name, - tier: component.minTier, + plan: t(TIER_INFO[component.minTier].labelKey), })} /> )} @@ -205,7 +207,7 @@ export function ComponentDetailModal({ />

{t("portal.catalogue.detail.pricing.note", { - unit: component.pricing.unit, + unit: t(BILLING_UNIT_LABEL[component.pricing.unit]), })}

diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index ce78ded6fa..69e23cb406 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { rightSection={ - {KEY_LABEL[apiKey.status]} + {t(KEY_LABEL[apiKey.status])} - {group.label} + {t(group.label)} {blurb && ( diff --git a/frontend/editor/src/portal/components/users/PendingInvitations.tsx b/frontend/editor/src/portal/components/users/PendingInvitations.tsx index acf9f96386..5c6fd65b5a 100644 --- a/frontend/editor/src/portal/components/users/PendingInvitations.tsx +++ b/frontend/editor/src/portal/components/users/PendingInvitations.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Avatar, Button } from "@app/ui"; import type { PendingInvitation } from "@portal/api/users"; import "@portal/views/Users.css"; @@ -11,13 +12,15 @@ interface PendingInvitationsProps { /** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable, * or already past (the adapter filters expired invites, so no "expired" state). */ -function expiryLabel(iso: string | undefined, expiresWord: string): string { +function expiryLabel(iso: string | undefined, t: TFunction): string { if (!iso) return ""; const ts = Date.parse(iso); if (!Number.isFinite(ts) || ts <= Date.now()) return ""; const days = Math.round((ts - Date.now()) / 86400000); - if (days === 0) return `${expiresWord} today`; - return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`; + if (days === 0) return t("users.invites.expiresToday", "Expires today"); + return t("users.invites.expiresInDays", "Expires in {{count}} days", { + count: days, + }); } /** @@ -30,7 +33,6 @@ export function PendingInvitations({ onCancel, }: PendingInvitationsProps) { const { t } = useTranslation(); - const expiresWord = t("users.invites.expires", "Expires"); return (
@@ -50,7 +52,7 @@ export function PendingInvitations({
{invitations.map((inv) => { - const expires = expiryLabel(inv.expiresAt, expiresWord); + const expires = expiryLabel(inv.expiresAt, t); return (
diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index cb8a730640..152e1f7a0b 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -10,16 +10,20 @@ import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; export interface TierInfo { - label: string; + /** i18n key for the plan label; resolve with `t()` at the call site. */ + labelKey: string; dotColor: string; } export const TIER_INFO: Record = { // Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free // manual-tools tier is "Editor plan"; the metered tier is "Processor plan". - free: { label: "Editor plan", dotColor: "var(--color-text-4)" }, - pro: { label: "Processor plan", dotColor: "var(--color-blue)" }, - enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" }, + free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" }, + pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" }, + enterprise: { + labelKey: "portal.tier.enterprise", + dotColor: "var(--color-purple)", + }, }; interface TierContextValue { diff --git a/frontend/editor/src/portal/mocks/procurement.ts b/frontend/editor/src/portal/mocks/procurement.ts index 1f3f5a1934..6976601d7b 100644 --- a/frontend/editor/src/portal/mocks/procurement.ts +++ b/frontend/editor/src/portal/mocks/procurement.ts @@ -51,7 +51,7 @@ const ENTERPRISE_DEAL: Deal = { const ENTERPRISE_LEDGER: LedgerGroup[] = [ { stage: "trial", - label: "Trial", + label: "portal.procurement.journeySteps.trial.label", docs: [ { id: "doc-trial-quickstart", @@ -71,7 +71,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "quote", - label: "Quote", + label: "portal.procurement.journeySteps.quote.label", docs: [ { id: "doc-quote-formal", @@ -84,7 +84,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "security", - label: "Agreement", + label: "portal.procurement.journeySteps.agreement.label", docs: [ { id: "doc-agreement-enterprise", @@ -97,7 +97,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "procurement", - label: "Payment", + label: "portal.procurement.journeySteps.payment.label", docs: [ { id: "doc-pay-online", @@ -124,7 +124,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [ }, { stage: "active", - label: "Implementation", + label: "portal.procurement.journeySteps.implementation.label", docs: [ { id: "doc-active-playbook", diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 0795cef0f0..8d58ed0e79 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -67,7 +67,7 @@ export function Policies() { ) { setPageError(null); try { - await savePolicy(buildWireFromSetup(entry, result)); + await savePolicy(buildWireFromSetup(entry, result, t)); setWizard(null); setDetail(null); refetch(); @@ -97,7 +97,7 @@ export function Policies() { if (!entry || !policy?.state.backendId) return; const enabled = policy.state.status === "paused"; void runLifecycle(() => - savePolicy(buildWireFromState(entry, policy, enabled)), + savePolicy(buildWireFromState(entry, policy, enabled, t)), ); } From 8bfcf6eb7e2103a24bc912ab1746525a598d84ea Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:26:00 +0100 Subject: [PATCH 013/109] Portal: theme-aware code blocks and hero-navy token (#7003) # Description of Changes Makes the code-snippet boxes theme-aware (a light palette in light mode) and moves the hero navy into a design token without changing the colour itself. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after after-codeblock-light before-codeblock-light --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/editor/src/core/tokens/tokens.css | 31 +++++++++++++++++-- frontend/editor/src/core/ui/CodeBlock.css | 3 +- .../portal/components/EditorStatusCard.css | 4 +-- .../src/portal/components/WelcomeBanner.css | 4 +-- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index 247cd22a4e..f667a93851 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -270,8 +270,29 @@ --grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%); } -/* Always-dark code palette. Not theme-switched. */ -:root { +/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark + terminal palette in dark mode. (CodeBlock renders plain text, so only the + base colours are load-bearing; the syntax slots are kept for future Shiki. */ +:root, +[data-theme="light"] { + --code-bg: #f6f8fa; + --code-bg-alt: #eef1f4; + --code-bg-header: #eaeef2; + --code-text: #1f2328; + --code-dim: #656d76; + --code-muted: #8c959f; + --code-keyword: #cf222e; + --code-string: #0a3069; + --code-number: #0550ae; + --code-fn: #8250df; + --code-type: #953800; + --code-property: #0550ae; + --code-comment: #6e7781; + --code-border: #d0d7de; + /* Window-chrome traffic-light dots in the code-block header. */ + --code-dot: #d0d7de; +} +[data-theme="dark"] { --code-bg: #0f172a; --code-bg-alt: #1e293b; --code-bg-header: #1a2332; @@ -286,12 +307,16 @@ --code-property: #93c5fd; --code-comment: #475569; --code-border: #1e293b; - /* Window-chrome traffic-light dots in the code-block header. */ --code-dot: #475569; } /* Radii / typography / motion / spacing / z-index — theme-stable */ :root { + /* Home hero strip navy. Theme-stable by design — the hero keeps this deep + navy in both light and dark (it's a branded surface, like the assistant + header), so it's defined once here rather than in the light/dark blocks. */ + --color-hero-navy: #16213e; + --radius-xs: 0.1875rem; --radius-sm: 0.25rem; --radius-md: 0.375rem; diff --git a/frontend/editor/src/core/ui/CodeBlock.css b/frontend/editor/src/core/ui/CodeBlock.css index b74a03c67f..76ebea940c 100644 --- a/frontend/editor/src/core/ui/CodeBlock.css +++ b/frontend/editor/src/core/ui/CodeBlock.css @@ -51,7 +51,8 @@ transition: background var(--motion-fast); } .sui-code__copy:hover { - background: rgba(255, 255, 255, 0.05); + /* Subtle tint that reads on both the light and dark code surfaces. */ + background: color-mix(in srgb, var(--code-text) 8%, transparent); } .sui-code__pre { margin: 0; diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 83426bae91..9732ec2fb0 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -15,7 +15,7 @@ align-items: center; gap: 1.25rem; padding: 1rem 1.25rem; - background: #16213e; + background: var(--color-hero-navy); } .portal-editor-hero__logo { @@ -123,7 +123,7 @@ .portal-editor-hero__action .portal-editor-hero__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: #16213e; + color: var(--color-hero-navy); } .portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 5c63462754..57f54846eb 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -18,7 +18,7 @@ gap: 1rem; flex-wrap: wrap; padding: 0.875rem 1.25rem; - background: #16213e; + background: var(--color-hero-navy); } .portal-welcome__brand { @@ -90,7 +90,7 @@ .portal-welcome__header .portal-welcome__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: #16213e; + color: var(--color-hero-navy); } .portal-welcome__header .portal-welcome__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); From b5d0c4a5ed740fc9de9c76ff1d6e91902a981ac0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:26:07 +0100 Subject: [PATCH 014/109] Portal Home: SVG quick-action icons (#6998) # Description of Changes Replaces the ASCII quick-action glyphs on the Home hero with crisp stroke SVG icons. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after after-home-dark after-home-light before-home-dark before-home-light --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/editor/src/portal/views/Home.css | 13 +++++-- frontend/editor/src/portal/views/Home.tsx | 42 +++++++++++++++++++---- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index e1af68b768..3027dfa1bc 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -93,8 +93,11 @@ width: 1.75rem; height: 1.75rem; border-radius: var(--radius-md); - font-size: 0.875rem; - font-weight: 600; + flex-shrink: 0; +} +.portal-home__quick-icon svg { + width: 1.05rem; + height: 1.05rem; } .portal-home__quick-text { @@ -116,9 +119,13 @@ } .portal-home__quick-arrow { - font-size: 0.875rem; + display: inline-flex; color: var(--color-text-5); } +.portal-home__quick-arrow svg { + width: 1rem; + height: 1rem; +} .portal-home__quick-row:hover .portal-home__quick-arrow { color: var(--color-blue); diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index a1829d5476..2f8f812581 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -13,31 +13,52 @@ import "@portal/views/Home.css"; /* Quick actions card */ /* ──────────────────────────────────────────────────────────────────────── */ +/** A stroke icon used inside the quick-action badge (replaces the old text + glyphs ⌃ ⇢ ⚙, which rendered off-style vs the portal's icon set). */ +function QuickIcon({ d }: { d: string }) { + return ( + + + + ); +} + /** Rows for the Quick Actions list. Each `view` navigates the portal. */ const QUICK_ACTIONS: Array<{ key: string; - glyph: string; + iconD: string; bg: string; fg: string; view: ViewId; }> = [ { key: "buildPipeline", - glyph: "⌃", + iconD: + "M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4", bg: "var(--color-purple-light)", fg: "var(--color-purple)", view: "pipelines", }, { key: "connectSource", - glyph: "⇢", + iconD: + "M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1", bg: "var(--color-green-light)", fg: "var(--color-green-dark)", view: "sources", }, { key: "issueApiKey", - glyph: "⚙", + iconD: + "M2.6 17.4A2 2 0 002 18.8V21a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 011-1h1a1 1 0 001-1v-1a1 1 0 011-1h.2a2 2 0 001.4-.6l.8-.8a6.5 6.5 0 10-4-4z M16.5 7.5 h.01", bg: "var(--color-amber-light)", fg: "var(--color-amber-dark)", view: "infrastructure", @@ -75,12 +96,21 @@ function QuickActions() { style={{ background: action.bg, color: action.fg }} aria-hidden > - {action.glyph} + } rightSection={ - → + + + } > From 76549288a9403c78b29406f32f74d52adc43e6d9 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 13 Jul 2026 15:44:41 +0100 Subject: [PATCH 015/109] Redesign S3 connections to use connection resolver (#6965) # Description of Changes Redesign S3 connections based on feedback from #6948. Also redesigns the UI for Sources to make them more like the Pipelines page which improves UX quite a bit. There's still plenty more UI/UX work for Sources and S3 but moving in the right direction. --- .../service/IntegrationConfigService.java | 44 ++- .../service/IntegrationConfigUsageCheck.java | 15 + .../service/IntegrationConfigValidator.java | 23 ++ .../policy/controller/PolicyController.java | 20 ++ .../policy/engine/PolicyValidator.java | 14 +- .../policy/input/S3InputSource.java | 27 +- .../policy/output/S3OutputSink.java | 12 +- .../s3/EmbeddedS3CredentialMigration.java | 212 ++++++++++++ .../s3/PolicyS3ConnectionUsageCheck.java | 56 +++ .../proprietary/policy/s3/S3Config.java | 10 +- .../policy/s3/S3ConnectionResolver.java | 151 ++++++++ .../policy/s3/S3IntegrationValidator.java | 51 +++ .../service/IntegrationConfigServiceTest.java | 55 ++- .../controller/PolicyControllerTest.java | 24 ++ .../policy/engine/PolicyValidatorTest.java | 22 ++ .../policy/input/S3InputSourceMinioTest.java | 9 +- .../policy/input/S3InputSourceTest.java | 4 +- .../policy/output/S3OutputSinkMinioTest.java | 5 +- .../policy/output/S3OutputSinkTest.java | 2 + .../s3/EmbeddedS3CredentialMigrationTest.java | 212 ++++++++++++ .../s3/PolicyS3ConnectionUsageCheckTest.java | 52 +++ .../policy/s3/S3ConnectionResolverTest.java | 149 ++++++++ .../policy/s3/S3IntegrationValidatorTest.java | 71 ++++ .../policy/s3/S3TestConnections.java | 24 ++ .../public/locales/en-US/translation.toml | 81 +++-- frontend/editor/src/core/ui/Table.tsx | 73 ++-- frontend/editor/src/portal/ViewRouter.tsx | 9 + .../editor/src/portal/api/integrations.ts | 74 ++++ .../components/documents/ReviewQueue.tsx | 2 +- .../sources/ConnectWizard.stories.tsx | 16 - .../components/sources/ConnectWizard.test.tsx | 192 ----------- .../components/sources/ConnectWizard.tsx | 298 ---------------- .../sources/ConnectionsTab.test.tsx | 82 +++++ .../components/sources/ConnectionsTab.tsx | 186 ++++++++++ .../components/sources/S3ConnectionForm.tsx | 116 +++++++ .../sources/S3ConnectionModal.test.tsx | 131 +++++++ .../components/sources/S3ConnectionModal.tsx | 127 +++++++ .../sources/S3ConnectionPicker.test.tsx | 72 ++++ .../components/sources/S3ConnectionPicker.tsx | 71 ++++ .../sources/SourceDetailCard.stories.tsx | 63 ---- .../components/sources/SourceDetailCard.tsx | 100 ------ .../sources/SourceDetailPanel.stories.tsx | 59 ---- .../components/sources/SourceDetailPanel.tsx | 91 ----- .../sources/SourcesTable.stories.tsx | 7 +- .../components/sources/SourcesTable.tsx | 45 ++- .../components/sources/Sparkline.test.tsx | 48 --- .../portal/components/sources/Sparkline.tsx | 53 --- .../portal/components/sources/sourceTypes.ts | 36 +- .../src/portal/views/PipelineBuilder.css | 24 -- .../src/portal/views/PipelineBuilder.test.tsx | 84 +++-- .../src/portal/views/PipelineBuilder.tsx | 149 ++------ .../editor/src/portal/views/Pipelines.tsx | 2 +- .../editor/src/portal/views/SourceBuilder.css | 87 +++++ .../src/portal/views/SourceBuilder.test.tsx | 158 +++++++++ .../editor/src/portal/views/SourceBuilder.tsx | 326 ++++++++++++++++++ frontend/editor/src/portal/views/Sources.css | 98 ++++++ .../editor/src/portal/views/Sources.test.tsx | 207 ++++------- frontend/editor/src/portal/views/Sources.tsx | 273 ++++----------- 58 files changed, 3127 insertions(+), 1577 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java create mode 100644 frontend/editor/src/portal/api/integrations.ts delete mode 100644 frontend/editor/src/portal/components/sources/ConnectWizard.stories.tsx delete mode 100644 frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx delete mode 100644 frontend/editor/src/portal/components/sources/ConnectWizard.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionsTab.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx delete mode 100644 frontend/editor/src/portal/components/sources/SourceDetailCard.stories.tsx delete mode 100644 frontend/editor/src/portal/components/sources/SourceDetailCard.tsx delete mode 100644 frontend/editor/src/portal/components/sources/SourceDetailPanel.stories.tsx delete mode 100644 frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx delete mode 100644 frontend/editor/src/portal/components/sources/Sparkline.test.tsx delete mode 100644 frontend/editor/src/portal/components/sources/Sparkline.tsx create mode 100644 frontend/editor/src/portal/views/SourceBuilder.css create mode 100644 frontend/editor/src/portal/views/SourceBuilder.test.tsx create mode 100644 frontend/editor/src/portal/views/SourceBuilder.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index 9e44b35523..a7ef7d4512 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -43,6 +43,10 @@ public class IntegrationConfigService { private final OwnershipService ownership; private final SecretMasker secretMasker; private final ResourceGrantRepository grantRepository; + // Bean-discovered extension points: features that understand a type contribute its config + // schema and report what still references a config, without this module depending on them. + private final List validators; + private final List usageChecks; // ---- commands ---- @@ -66,13 +70,21 @@ public class IntegrationConfigService { ? DefaultAccessPolicy.EXPLICIT_ONLY : request.defaultAccess()); + // TEAM scope may omit the team id: default to the caller's own team so clients (the + // portal) need not know it. assignOwnership still enforces admin-or-leader of that team. + Long ownerTeamId = request.ownerTeamId(); + if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) { + ownerTeamId = currentUser.getTeam().getId(); + } ownership.assignOwnership( cfg, scope, - request.ownerTeamId(), + ownerTeamId, currentUser, () -> lockedServerExists(cfg.getIntegrationType())); - cfg.setConfig(writeJson(secretMasker.sanitize(request.config()))); + Map config = secretMasker.sanitize(request.config()); + validateConfig(cfg.getIntegrationType(), config); + cfg.setConfig(writeJson(config)); return repository.save(cfg); } @@ -101,8 +113,10 @@ public class IntegrationConfigService { cfg.setDefaultAccess(request.defaultAccess()); } if (request.config() != null) { - cfg.setConfig( - writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config()))); + Map merged = + secretMasker.merge(readJson(cfg.getConfig()), request.config()); + validateConfig(cfg.getIntegrationType(), merged); + cfg.setConfig(writeJson(merged)); } return repository.save(cfg); } @@ -113,6 +127,15 @@ public class IntegrationConfigService { if (!ownership.canManage(TYPE, cfg, currentUser)) { throw forbidden("You cannot manage this integration"); } + // Refuse to pull a connection out from under whatever still references it. + List usages = + usageChecks.stream() + .flatMap(check -> check.usagesOf(cfg.getId()).stream()) + .toList(); + if (!usages.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages)); + } // Drop grants sharing this config so they do not dangle as dead rows. grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); repository.delete(cfg); @@ -188,6 +211,19 @@ public class IntegrationConfigService { // ---- integration-specific glue ---- + /** Runs every registered validator for the type; unknown types save free-form. */ + private void validateConfig(IntegrationType type, Map config) { + for (IntegrationConfigValidator validator : validators) { + if (validator.type() == type) { + try { + validator.validate(config == null ? Map.of() : config); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + } + } + /** A non-admin can't create a personal config of a type an admin has locked at server scope. */ private boolean lockedServerExists(IntegrationType type) { return repository.findByScope(OwnerScope.SERVER).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java new file mode 100644 index 0000000000..6d703baeda --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.integration.service; + +import java.util.List; + +/** + * Reports what still references an integration config, so deletion can be refused instead of + * pulling a connection out from under a live consumer. Implementations are beans discovered by + * {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that + * reference a connection). + */ +public interface IntegrationConfigUsageCheck { + + /** Human-readable labels of everything still using the config; empty when unreferenced. */ + List usagesOf(long configId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java new file mode 100644 index 0000000000..05857d2714 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.integration.service; + +import java.util.Map; + +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Validates one integration type's config map at save time. Implementations are beans discovered by + * {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3 + * backend) owns its schema without the integration module depending on it. Types with no registered + * validator save free-form. + */ +public interface IntegrationConfigValidator { + + /** The type this validator understands. */ + IntegrationType type(); + + /** + * Validates the config as it will be stored (secrets already sanitized/merged, so values are + * real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config. + */ + void validate(Map config); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index e2c89f6a54..95fde9304a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -120,6 +120,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocOutput(definition); PolicyInputs inputs = toInputs(files); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); @@ -140,6 +141,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocOutput(definition); PolicyInputs inputs = toInputs(files); SseEmitter emitter = @@ -530,6 +532,24 @@ public class PolicyController { } } + /** + * Authorization-check an ad-hoc run's output while the caller's principal is present (this + * request thread). The worker thread that later delivers carries no security context, so an S3 + * output's connection-access check would be skipped there; without this gate a caller could + * reference another tenant's connection by id and write to it (confused deputy). Stored + * policies are covered by save-time {@link PolicyValidator#validate} instead. + */ + private void validateAdHocOutput(PipelineDefinition definition) { + if (definition.output() == null) { + return; + } + try { + policyValidator.validateOutput(definition.output()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + /** * Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents * feed the same virtual editor source as stored editor policies, counted against the caller's diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index c08d2dd857..92c4cf95d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -52,7 +52,19 @@ public class PolicyValidator { InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } - outputSinkFor(policy.output()).validate(policy.output()); + validateOutput(policy.output()); + } + + /** + * Validate an output spec against its sink. Must be called on a request thread (caller's + * principal present) so an S3 output's connection is authorization-checked against the caller - + * ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker + * thread that later delivers has no principal, so this is their only access gate. + * + * @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible + */ + public void validateOutput(OutputSpec output) { + outputSinkFor(output).validate(output); } private PolicyTrigger triggerFor(TriggerConfig config) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java index 99e189f326..fbbcfc4549 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -18,6 +18,7 @@ import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.s3.S3Config; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -36,15 +37,14 @@ import software.amazon.awssdk.services.s3.model.S3Object; * Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit * of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and * version gate come from {@link S3Identities}, so the steady-state sweep never downloads content. - * Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only - * keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style - * addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are - * never signed with the server's own AWS identity), and "mode" which is "consume" (default: a - * processed object is deleted once every policy that claimed it has settled successfully and it is - * still the version that ran; failures stay in place and are not retried until they change) or - * "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and - * keys with a dot-prefixed path segment are never picked up, mirroring the folder source's - * hidden-file rule. + * Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning + * bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix" + * (only keys starting with it are read) and "mode" are per-source, where mode is "consume" + * (default: a processed object is deleted once every policy that claimed it has settled + * successfully and it is still the version that ran; failures stay in place and are not retried + * until they change) or "snapshot" (stateless, every run sees the full set). Keys ending in "/" + * (folder placeholders) and keys with a dot-prefixed path segment are never picked up, mirroring + * the folder source's hidden-file rule. */ @Slf4j @Service @@ -55,6 +55,7 @@ public class S3InputSource implements InputSource { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; @Override public String type() { @@ -67,12 +68,12 @@ public class S3InputSource implements InputSource { } /** - * Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or - * a bucket the supplied credentials cannot list. + * Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private + * endpoint without the operator opt-in, or a bucket the connection cannot list. */ @Override public void validate(InputSpec spec) { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); try { connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build()); } catch (SdkException e) { @@ -89,7 +90,7 @@ public class S3InputSource implements InputSource { @Override public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); S3Client client = connectionPool.clientFor(config); // A listing failure propagates so the sweep reads it as "could not list" (which vetoes // presence cleanup), never as "verifiably no objects". diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index c7d740868a..fb590b3c2e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -27,6 +27,7 @@ import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3Config; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; private final ProcessedLedger processedLedger; @Override @@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink { } /** - * Config shape and endpoint guard only - no network probe, since write-only credentials - * (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a - * listing probe would wrongly reject them. + * Connection resolution (including the saving user's right to use it) and endpoint guard only - + * no network probe, since write-only credentials (s3:PutObject without s3:ListBucket) are a + * legitimate setup for an output bucket and a listing probe would wrongly reject them. */ @Override public void validate(OutputSpec spec) { - connectionPool.clientFor(S3Config.from(spec.options())); + connectionPool.clientFor(connectionResolver.resolve(spec.options())); } @Override public List deliver( OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); S3Client client = connectionPool.clientFor(config); List results = new ArrayList<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java new file mode 100644 index 0000000000..5aa82a8e66 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; + +import tools.jackson.databind.ObjectMapper; + +/** + * One-time, idempotent extraction of legacy embedded S3 credentials into stored connections: + * sources and policy outputs written before connections shipped carry bucket/credentials in their + * own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and + * keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at + * the application layer, so no SQL migration can read it. + * + *

Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find + * nothing to do. Connections are deduplicated against both this run's extractions and existing S3 + * connections; a concurrent multi-node boot can at worst create a redundant connection row, never + * corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a + * team, server-scoped otherwise (single-operator self-hosted). + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class EmbeddedS3CredentialMigration { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final List CONNECTION_OPTIONS = + List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey"); + // Field separator for the dedup key: a unit-separator control char that cannot appear in a + // bucket/region/endpoint/credential, so distinct field sets can never collide. + private static final char DELIMITER = '\u001f'; + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + private final IntegrationConfigRepository connections; + private final TeamRepository teamRepository; + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void migrate() { + Map byCredentialKey = indexExistingConnections(); + int migrated = 0; + for (Source source : sourceStore.all()) { + if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(source.options(), source.teamId(), byCredentialKey); + sourceStore.save(withOptions(source, referencing(connection, source.options(), true))); + migrated++; + } + for (Policy policy : policyStore.all()) { + OutputSpec output = policy.output(); + if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(output.options(), policy.teamId(), byCredentialKey); + policyStore.save( + withOutput( + policy, + new OutputSpec( + output.type(), + referencing(connection, output.options(), false)))); + migrated++; + } + if (migrated > 0) { + log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated); + } + } + + private static boolean embedsCredentials(Map options) { + return options.get("accessKeyId") != null; + } + + /** Reuses an existing connection with identical coordinates+credentials, else creates one. */ + private IntegrationConfig connectionFor( + Map options, Long teamId, Map byKey) { + String key = credentialKey(options); + IntegrationConfig existing = byKey.get(key); + if (existing != null) { + return existing; + } + IntegrationConfig connection = new IntegrationConfig(); + connection.setIntegrationType(IntegrationType.S3); + connection.setName(connectionName(options, byKey)); + connection.setEnabled(true); + connection.setLocked(false); + connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); + Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null); + if (team != null) { + connection.setScope(OwnerScope.TEAM); + connection.setOwnerTeam(team); + } else { + // No team (teamless self-hosted, or a source whose team was since deleted): server + // scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need + // an admin to re-share the connection - acceptable for the narrow orphaned case. + connection.setScope(OwnerScope.SERVER); + } + Map config = new LinkedHashMap<>(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + if (value != null && !value.toString().isBlank()) { + config.put(option, value); + } + } + connection.setConfig(OBJECT_MAPPER.writeValueAsString(config)); + IntegrationConfig saved = connections.save(connection); + byKey.put(key, saved); + return saved; + } + + /** The rewritten options: the connection reference plus per-use settings only. */ + private static Map referencing( + IntegrationConfig connection, Map legacy, boolean keepMode) { + Map options = new LinkedHashMap<>(); + options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId()); + Object prefix = legacy.get("prefix"); + if (prefix != null && !prefix.toString().isBlank()) { + options.put("prefix", prefix); + } + Object mode = legacy.get("mode"); + if (keepMode && mode != null && !mode.toString().isBlank()) { + options.put("mode", mode); + } + return options; + } + + private Map indexExistingConnections() { + Map byKey = new LinkedHashMap<>(); + for (IntegrationConfig connection : connections.findAll()) { + if (connection.getIntegrationType() != IntegrationType.S3) { + continue; + } + try { + Map config = + OBJECT_MAPPER.readValue(connection.getConfig(), Map.class); + byKey.putIfAbsent(credentialKey(config), connection); + } catch (Exception e) { + log.debug( + "Skipping unreadable S3 connection {} while indexing: {}", + connection.getId(), + e.getMessage()); + } + } + return byKey; + } + + private static String credentialKey(Map options) { + StringBuilder key = new StringBuilder(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + key.append(value == null ? "" : value.toString().trim()).append(DELIMITER); + } + return key.toString(); + } + + private static String connectionName( + Map options, Map byKey) { + String base = "S3: " + options.getOrDefault("bucket", "bucket"); + long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count(); + return sameName == 0 ? base : base + " (" + (sameName + 1) + ")"; + } + + private static Source withOptions(Source source, Map options) { + return new Source( + source.id(), + source.name(), + source.type(), + options, + source.enabled(), + source.owner(), + source.teamId()); + } + + private static Policy withOutput(Policy policy, OutputSpec output) { + return new Policy( + policy.id(), + policy.name(), + policy.owner(), + policy.enabled(), + policy.trigger(), + policy.sourceIds(), + policy.steps(), + output, + policy.teamId()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java new file mode 100644 index 0000000000..00d1b28e6c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java @@ -0,0 +1,56 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection + * cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source + * delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live + * stores. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck { + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + + @Override + public List usagesOf(long configId) { + List usages = new ArrayList<>(); + for (Source source : sourceStore.all()) { + if (references(source.options(), configId)) { + usages.add("source '" + source.name() + "'"); + } + } + for (Policy policy : policyStore.all()) { + if (references(policy.output().options(), configId)) { + usages.add("pipeline '" + policy.name() + "'"); + } + } + return usages; + } + + private static boolean references(Map options, long configId) { + try { + Long reference = S3ConnectionResolver.connectionId(options); + return reference != null && reference == configId; + } catch (IllegalArgumentException unparseable) { + return false; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java index 152a6de4cf..c9d3eabd83 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -5,10 +5,12 @@ import java.net.URISyntaxException; import java.util.Map; /** - * Connection settings shared by the S3 input source and output sink, parsed from a spec's options - * map. Credentials are required: there is deliberately no fallback to the server's own AWS - * credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot} - * is input-only and ignored by the sink. + * The fully resolved connection settings the S3 input source and output sink run with - normally + * produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint, + * credentials) with per-use options (prefix, mode), or parsed directly from legacy options that + * still embed credentials. Credentials are required: there is deliberately no fallback to the + * server's own AWS credential chain, so user-supplied config can never borrow the host's identity. + * {@code snapshot} is input-only and ignored by the sink. */ public record S3Config( String bucket, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java new file mode 100644 index 0000000000..d15839aebe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java @@ -0,0 +1,151 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code + * connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region, + * endpoint, and credentials; the options own per-use settings such as prefix and mode). Options + * with no {@code connectionId} fall back to legacy embedded credentials, so rows written before + * connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them. + * + *

When an authenticated caller is present (save-time validation), they must be allowed to use + * the connection. Background sweeps and deliveries run with no caller and skip that check: the + * referencing source or policy was access-checked when it was saved. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3ConnectionResolver { + + static final String CONNECTION_ID_OPTION = "connectionId"; + private static final String PREFIX_OPTION = "prefix"; + private static final String MODE_OPTION = "mode"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository connections; + private final OwnershipService ownership; + private final UserService userService; + + public S3Config resolve(Map options) { + Long connectionId = connectionId(options); + if (connectionId == null) { + // Legacy embedded credentials, pending migration. + return S3Config.from(options); + } + IntegrationConfig connection = + connections + .findById(connectionId) + .filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3) + .filter(this::usableByCurrentUser) + // Existence and access collapse into one error: a caller must not be able + // to tell "no such connection" from "someone else's connection" and + // enumerate ids. The id/name are never echoed. + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown or inaccessible s3 connection")); + if (!connection.isEnabled()) { + throw new IllegalArgumentException("s3 connection is disabled"); + } + Map merged = new LinkedHashMap<>(connectionConfig(connection)); + copyPerUseOption(options, merged, PREFIX_OPTION); + copyPerUseOption(options, merged, MODE_OPTION); + return S3Config.from(merged); + } + + /** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */ + static Long connectionId(Map options) { + Object reference = options.get(CONNECTION_ID_OPTION); + if (reference == null || (reference instanceof String s && s.isBlank())) { + return null; + } + if (reference instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(reference.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "s3 'connectionId' is not a valid connection reference: " + reference); + } + } + + /** + * Whether the current caller may use this connection. With no principal - a background sweep or + * delivery on a worker thread that carries no {@code SecurityContext} - access is treated as + * already established: stored policies are validated with the caller present at save time, and + * ad-hoc runs are validated on the request thread before dispatch (see {@code + * PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing + * standing between a caller and a connection, or the check becomes a confused deputy. + */ + private boolean usableByCurrentUser(IntegrationConfig connection) { + User user = currentUser(); + return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user); + } + + // Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated. + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } + + private static Map connectionConfig(IntegrationConfig connection) { + String json = connection.getConfig(); + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException( + "s3 connection '" + connection.getName() + "' has unreadable config", e); + } + } + + private static void copyPerUseOption( + Map options, Map merged, String key) { + Object value = options.get(key); + if (value != null && !value.toString().isBlank()) { + merged.put(key, value); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java new file mode 100644 index 0000000000..ee36ba5693 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java @@ -0,0 +1,51 @@ +package stirling.software.proprietary.policy.s3; + +import java.net.URI; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** + * The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and + * credentials required, endpoint an http(s) URL that must not reach private addresses without the + * operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save + * time so a bad connection fails in the form rather than in a sweep. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3IntegrationValidator implements IntegrationConfigValidator { + + private final ApplicationProperties applicationProperties; + + @Override + public IntegrationType type() { + return IntegrationType.S3; + } + + @Override + public void validate(Map config) { + S3Config parsed = S3Config.from(config); + if (parsed.endpoint() == null) { + return; + } + try { + S3Clients.validateEndpointHost( + URI.create(parsed.endpoint()), + applicationProperties.getPolicies().isAllowPrivateS3Endpoints(), + "S3 connection endpoint", + "set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local" + + " MinIO)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index 8e0ef200bb..fbfef15721 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -13,9 +13,9 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -52,7 +52,58 @@ class IntegrationConfigServiceTest { @Mock private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository; - @InjectMocks private IntegrationConfigService service; + @Mock private IntegrationConfigValidator validator; + @Mock private IntegrationConfigUsageCheck usageCheck; + + private IntegrationConfigService service; + + @BeforeEach + void setUp() { + service = + new IntegrationConfigService( + repository, + ownership, + secretMasker, + grantRepository, + List.of(validator), + List.of(usageCheck)); + } + + @Test + void createRejectsAConfigItsTypeValidatorRefuses() { + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(validator.type()).thenReturn(IntegrationType.API); + org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'")) + .when(validator) + .validate(any()); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void deleteRefusedWhileAnythingStillReferencesTheConfig() { + IntegrationConfig cfg = config(9L); + when(repository.findById(9L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'")); + + assertThatThrownBy(() -> service.delete(9L, user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class)); + } @Test void createDelegatesOwnershipAndSanitizesConfig() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index c02945fdad..8258622e28 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -4,6 +4,7 @@ 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.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -194,6 +195,29 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST)); } + + @Test + @DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread") + void rejectsUnauthorizedAdHocOutput() { + // The confused-deputy guard: an S3 output referencing a connection the caller may not + // use is validated here (principal present) and refused before any worker dispatch. + PipelineDefinition definition = + new PipelineDefinition( + "pipe", + List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), + new OutputSpec("s3", Map.of("connectionId", 999))); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(policyValidator) + .validateOutput(any()); + + assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(policyRunner, never()).runAdHoc(any(), any(), any()); + } } @Nested diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 8cdb1b45a3..21a9f3e42e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -82,6 +82,28 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("schedule")); } + @Test + void validateOutputDelegatesToTheSink() { + when(outputSink.supports(any())).thenReturn(true); + OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1)); + + validator.validateOutput(output); + + verify(outputSink).validate(output); + } + + @Test + void validateOutputSurfacesAnInaccessibleConnection() { + when(outputSink.supports(any())).thenReturn(true); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(outputSink) + .validate(any()); + + assertThrows( + IllegalArgumentException.class, + () -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1)))); + } + @Test void rejectsAnUnknownTriggerType() { when(trigger.type()).thenReturn("schedule"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java index 4e1e1fc305..47de8f6092 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java @@ -23,6 +23,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -82,7 +83,9 @@ class S3InputSourceMinioTest { // The MinIO endpoint resolves to loopback, so the operator opt-in must be on. ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowPrivateS3Endpoints(true); - source = new S3InputSource(new S3ConnectionPool(properties)); + source = + new S3InputSource( + new S3ConnectionPool(properties), S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } @@ -161,7 +164,9 @@ class S3InputSourceMinioTest { @Test void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() { S3InputSource guarded = - new S3InputSource(new S3ConnectionPool(new ApplicationProperties())); + new S3InputSource( + new S3ConnectionPool(new ApplicationProperties()), + S3TestConnections.legacyResolver()); assertThatThrownBy(() -> guarded.validate(spec(Map.of()))) .isInstanceOf(IllegalArgumentException.class) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java index 73995248dd..9054b4f304 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java @@ -29,6 +29,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.exception.SdkClientException; @@ -64,7 +65,8 @@ class S3InputSourceTest { void setUp() { source = new S3InputSource( - new S3ConnectionPool(new ApplicationProperties(), config -> s3Client)); + new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java index a2a5a41ca0..153e6e1508 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java @@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -90,8 +91,8 @@ class S3OutputSinkMinioTest { properties.getPolicies().setAllowPrivateS3Endpoints(true); S3ConnectionPool pool = new S3ConnectionPool(properties); ledger = new InProcessProcessedLedger(); - sink = new S3OutputSink(pool, ledger); - source = new S3InputSource(pool); + sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger); + source = new S3InputSource(pool, S3TestConnections.legacyResolver()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java index 8240bcc4e1..a5a3327c51 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java @@ -32,6 +32,7 @@ import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedFileStatus; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.core.exception.SdkClientException; @@ -66,6 +67,7 @@ class S3OutputSinkTest { sink = new S3OutputSink( new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver(), ledger); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java new file mode 100644 index 0000000000..ff14d8cf81 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.s3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** + * Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated + * team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops. + */ +@ExtendWith(MockitoExtension.class) +class EmbeddedS3CredentialMigrationTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private TeamRepository teamRepository; + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private EmbeddedS3CredentialMigration migration; + + @BeforeEach + void setUp() { + migration = + new EmbeddedS3CredentialMigration( + sourceStore, policyStore, connections, teamRepository); + AtomicLong ids = new AtomicLong(100); + // Lenient: the nothing-to-migrate cases never create a connection. + lenient().when(connections.findAll()).thenReturn(List.of()); + lenient() + .when(connections.save(any())) + .thenAnswer( + invocation -> { + IntegrationConfig saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(ids.incrementAndGet()); + } + return saved; + }); + } + + @Test + void extractsSharedCredentialsIntoOneTeamScopedConnection() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + Source source = + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of( + "bucket", "inbox", + "prefix", "incoming/", + "mode", "snapshot", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + 7L)); + Policy policy = + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(source.id()), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec( + "s3", + Map.of( + "bucket", "inbox", + "prefix", "processed/", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")), + 7L)); + + migration.migrate(); + + // Same bucket + credentials on both rows: exactly one connection extracted. + verify(connections, times(1)).save(any()); + Map sourceOptions = sourceStore.get(source.id()).orElseThrow().options(); + assertEquals(101L, sourceOptions.get("connectionId")); + assertEquals("incoming/", sourceOptions.get("prefix")); + assertEquals("snapshot", sourceOptions.get("mode")); + assertNull(sourceOptions.get("accessKeyId")); + assertNull(sourceOptions.get("secretAccessKey")); + assertNull(sourceOptions.get("bucket")); + + Map outputOptions = + policyStore.get(policy.id()).orElseThrow().output().options(); + assertEquals(101L, outputOptions.get("connectionId")); + assertEquals("processed/", outputOptions.get("prefix")); + assertNull(outputOptions.get("secretAccessKey")); + } + + @Test + void connectionOwnershipFollowsTheSourceTeam() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + sourceStore.save(s3Source("teamed", 7L)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> + connection.getScope() == OwnerScope.TEAM + && connection.getOwnerTeam() == team)); + } + + @Test + void teamlessRowsBecomeServerScopedConnections() { + sourceStore.save(s3Source("solo", null)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> connection.getScope() == OwnerScope.SERVER)); + } + + @Test + void aSecondRunFindsNothingToDo() { + sourceStore.save(s3Source("once", null)); + + migration.migrate(); + migration.migrate(); + + // One connection from the first run; the rewritten source no longer embeds credentials. + verify(connections, times(1)).save(any()); + } + + @Test + void nonS3AndAlreadyMigratedRowsAreUntouched() { + Source folder = + sourceStore.save( + new Source( + null, + "Folder", + "folder", + Map.of("directory", "/in"), + true, + "alice", + null)); + Source migrated = + sourceStore.save( + new Source( + null, + "Done already", + "s3", + Map.of("connectionId", 55L, "prefix", "in/"), + true, + "alice", + null)); + + migration.migrate(); + + verify(connections, times(0)).save(any()); + assertEquals( + Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options()); + assertEquals( + Map.of("connectionId", 55L, "prefix", "in/"), + sourceStore.get(migrated.id()).orElseThrow().options()); + } + + private static Source s3Source(String name, Long teamId) { + return new Source( + null, + name, + "s3", + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java new file mode 100644 index 0000000000..847a553ba6 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; + +/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */ +class PolicyS3ConnectionUsageCheckTest { + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private final PolicyS3ConnectionUsageCheck check = + new PolicyS3ConnectionUsageCheck(sourceStore, policyStore); + + @Test + void reportsSourcesAndOutputsReferencingTheConnection() { + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of("connectionId", 5L, "prefix", "in/"), + true, + "alice", + null)); + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec("s3", Map.of("connectionId", "5")), + null)); + + assertThat(check.usagesOf(5)) + .containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'"); + assertThat(check.usagesOf(6)).isEmpty(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java new file mode 100644 index 0000000000..6a480842c9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.s3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the + * legacy embedded fallback, and the save-time access check that background sweeps skip. + */ +@ExtendWith(MockitoExtension.class) +class S3ConnectionResolverTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private OwnershipService ownership; + @Mock private UserService userService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void resolvesAConnectionAndMergesPerUseOptions() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + S3Config config = + resolver() + .resolve( + Map.of( + "connectionId", 9L, + "prefix", "incoming/", + "mode", "snapshot")); + + assertEquals("inbox", config.bucket()); + assertEquals("AKIAEXAMPLE", config.accessKeyId()); + assertEquals("incoming/", config.prefix()); + assertTrue(config.snapshot()); + } + + @Test + void acceptsAStringConnectionReference() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket()); + } + + @Test + void fallsBackToLegacyEmbeddedCredentials() { + S3Config config = + resolver() + .resolve( + Map.of( + "bucket", "legacy", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")); + + assertEquals("legacy", config.bucket()); + } + + @Test + void rejectsUnknownDisabledOrWrongTypeConnections() { + when(connections.findById(1L)).thenReturn(Optional.empty()); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 1L))); + + when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false))); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 2L))); + + IntegrationConfig mcp = s3Connection(3L, true); + mcp.setIntegrationType(IntegrationType.MCP); + when(connections.findById(3L)).thenReturn(Optional.of(mcp)); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 3L))); + } + + @Test + void anAuthenticatedSaverMustBeAllowedToUseTheConnection() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + User saver = new User(); + saver.setUsername("alice"); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of())); + when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false); + + // Denied reads the same as unknown and never echoes the connection name, so ids can't be + // enumerated by probing. + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 9L))); + try { + resolver().resolve(Map.of("connectionId", 9L)); + } catch (IllegalArgumentException e) { + org.junit.jupiter.api.Assertions.assertFalse( + e.getMessage().contains("Claims bucket"), + "access-denied error must not leak the connection name"); + } + } + + @Test + void backgroundSweepsWithNoUserSkipTheAccessCheck() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + // No authentication in the context: resolution succeeds without consulting ownership. + assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket()); + } + + private S3ConnectionResolver resolver() { + return new S3ConnectionResolver(connections, ownership, userService); + } + + private static IntegrationConfig s3Connection(long id, boolean enabled) { + IntegrationConfig connection = new IntegrationConfig(); + connection.setId(id); + connection.setIntegrationType(IntegrationType.S3); + connection.setName("Claims bucket"); + connection.setEnabled(enabled); + connection.setConfig( + "{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\"," + + "\"secretAccessKey\":\"shh\"}"); + return connection; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java new file mode 100644 index 0000000000..4c07968807 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing + * credentials, bad endpoints, and private endpoints without the operator opt-in. + */ +class S3IntegrationValidatorTest { + + @Test + void acceptsACompleteConnection() { + assertThatCode( + () -> + validator(false) + .validate( + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingCredentialsOrBucket() { + assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + validator(false) + .validate( + Map.of( + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsAPrivateEndpointWithoutTheOperatorOptIn() { + Map config = + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh", + "endpoint", "http://localhost:9000"); + + assertThatThrownBy(() -> validator(false).validate(config)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowPrivateS3Endpoints"); + assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException(); + } + + @Test + void itOnlyClaimsTheS3Type() { + org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type()); + } + + private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints); + return new S3IntegrationValidator(properties); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java new file mode 100644 index 0000000000..7649831d2a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.policy.s3; + +import static org.mockito.Mockito.mock; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.service.UserService; + +/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */ +public final class S3TestConnections { + + private S3TestConnections() {} + + /** + * A resolver for tests whose options embed credentials directly (the legacy pass-through path), + * so its collaborators are never touched. + */ + public static S3ConnectionResolver legacyResolver() { + return new S3ConnectionResolver( + mock(IntegrationConfigRepository.class), + mock(OwnershipService.class), + mock(UserService.class)); + } +} diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index cc65a78ce8..c182ce2b89 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6534,6 +6534,35 @@ title = "No components available" description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge." title = "Some components need a paid plan" +[portal.connections] +createTitle = "New S3 connection" +delete = "Delete" +edit = "Edit" +editTitle = "Edit S3 connection" +subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to." + +[portal.connections.actions] +new = "New connection" + +[portal.connections.empty] +description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs." +title = "No connections yet" + +[portal.connections.picker] +cancel = "Cancel" +createNew = "New connection..." +placeholder = "Select a connection" +save = "Save connection" + +[portal.connections.s3.fields] +name = "Connection name" +namePlaceholder = "e.g. Claims bucket" + +[portal.connections.table] +bucket = "Bucket" +name = "Name" +region = "Region" + [portal.docs.authentication] codeCaption = "every request" eyebrow = "GETTING STARTED" @@ -7345,10 +7374,6 @@ operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" output = "Output" removeStep = "Remove operation" -s3Configure = "Configure" -s3Done = "Done" -s3ModalTitle = "Amazon S3 output" -s3NotConfigured = "Not configured" s3PrefixHelp = "Outputs are uploaded under this key prefix." save = "Save changes" scheduleEvery = "Run every" @@ -7991,34 +8016,29 @@ primaryNav = "Primary navigation" switchApp = "Switch app" [portal.sources] -subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it." +subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines." title = "Sources" [portal.sources.actions] agentBuilder = "Agent Builder" connectSource = "Connect source" +[portal.sources.builder] +back = "Back to sources" +cancel = "Cancel" +create = "Create source" +createTitle = "Connect a source" +delete = "Delete" +editTitle = "Edit source" +enabled = "Enabled" +save = "Save changes" + [portal.sources.delete] body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." cancel = "Cancel" confirm = "Delete" title = "Delete source?" -[portal.sources.detail] -closeAriaLabel = "Close detail" -delete = "Delete source" -docs24h = "Last 24h" -docs30d = "Last 30 days" -docsTotal = "Total seen" -docsTrend = "Documents over the last 30 days" -documents = "Documents" -edit = "Edit" -notReferenced = "Not referenced by any policy, so it's safe to delete." -pause = "Pause" -resume = "Resume" -subtitle = "{{type}} · {{status}}" -usedBy = "Used by" - [portal.sources.empty] description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." title = "No sources connected yet" @@ -8034,10 +8054,15 @@ disabled = "Disabled" unused = "Unused" [portal.sources.table] +documents = "Documents" source = "Source" status = "Status" usedBy = "Policies" +[portal.sources.tabs] +connections = "Connections" +sources = "Sources" + [portal.sources.types.editor] description = "Documents your team has processed in the editor, across policy and AI runs." label = "Editor" @@ -8085,6 +8110,10 @@ label = "Access key ID" label = "Bucket" placeholder = "my-company-inbox" +[portal.sources.types.s3.fields.connection] +helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it." +label = "Connection" + [portal.sources.types.s3.fields.endpoint] helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO." label = "Custom endpoint" @@ -8114,22 +8143,10 @@ label = "Secret access key" label = "Source" [portal.sources.wizard] -back = "Back" -cancel = "Cancel" -continue = "Continue" -editTitle = "Edit source" name = "Name" namePlaceholder = "e.g. Claims intake" -save = "Save changes" -subtitle = "Step {{current}} of {{total}} · {{label}}" -title = "Connect a source" type = "Type" -[portal.sources.wizard.steps] -chooseType = "Choose type" -configure = "Configure" -review = "Review & connect" - [portal.tier] enterprise = "Enterprise plan" free = "Editor plan" diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx index 0a07315a3d..fcd50e5fd6 100644 --- a/frontend/editor/src/core/ui/Table.tsx +++ b/frontend/editor/src/core/ui/Table.tsx @@ -19,6 +19,12 @@ export interface TableProps { rowKey: (row: T) => string; /** Makes rows interactive (hover + click + keyboard). */ onRowClick?: (row: T) => void; + /** + * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which + * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to + * all rows interactive. + */ + isRowInteractive?: (row: T) => boolean; /** Rendered in place of the body when there are no rows. */ empty?: ReactNode; className?: string; @@ -35,6 +41,7 @@ export function Table({ rows, rowKey, onRowClick, + isRowInteractive, empty, className, }: TableProps) { @@ -66,38 +73,42 @@ export function Table({ ) : ( - rows.map((row) => ( - onRowClick(row) : undefined} - tabIndex={interactive ? 0 : undefined} - role={interactive ? "button" : undefined} - onKeyDown={ - interactive - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); + rows.map((row) => { + const rowInteractive = + interactive && (isRowInteractive?.(row) ?? true); + return ( + onRowClick?.(row) : undefined} + tabIndex={rowInteractive ? 0 : undefined} + role={rowInteractive ? "button" : undefined} + onKeyDown={ + rowInteractive + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(row); + } } - } - : undefined - } - > - {columns.map((c) => ( - - {c.render(row)} - - ))} - - )) + : undefined + } + > + {columns.map((c) => ( + + {c.render(row)} + + ))} + + ); + }) )} diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 5e6af09e38..4419455a4c 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -5,6 +5,7 @@ import { Documents } from "@portal/views/Documents"; import { Pipelines } from "@portal/views/Pipelines"; import { PipelineBuilder } from "@portal/views/PipelineBuilder"; import { Sources } from "@portal/views/Sources"; +import { SourceBuilder } from "@portal/views/SourceBuilder"; import { AgentBuilder } from "@portal/views/AgentBuilder"; import { Policies } from "@portal/views/Policies"; import { Components } from "@portal/views/Components"; @@ -36,6 +37,14 @@ export function ViewRouter() { element={} /> } /> + } + /> + } + /> } diff --git a/frontend/editor/src/portal/api/integrations.ts b/frontend/editor/src/portal/api/integrations.ts new file mode 100644 index 0000000000..19f88fea29 --- /dev/null +++ b/frontend/editor/src/portal/api/integrations.ts @@ -0,0 +1,74 @@ +/** + * Integrations service layer: stored connections (S3 today; MCP/API later) that + * policy sources and pipeline outputs reference by id instead of embedding + * credentials. Secrets are write-only - reads return them masked, and sending + * the mask back on update keeps the stored value. + */ +import { apiClient } from "@portal/api/http"; + +export type IntegrationType = "S3" | "MCP" | "API"; +export type OwnerScope = "USER" | "TEAM" | "SERVER"; + +/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */ +export interface IntegrationConfig { + id: number; + integrationType: IntegrationType; + name: string; + scope: OwnerScope; + ownerUserId: number | null; + ownerTeamId: number | null; + enabled: boolean; + locked: boolean; + defaultAccess: string; + config: Record; + canManage: boolean; + createdAt: string; + updatedAt: string; +} + +/** Create/update body; omitted fields keep their stored values on update. */ +export interface IntegrationConfigRequest { + integrationType?: IntegrationType; + name?: string; + scope?: OwnerScope; + ownerTeamId?: number | null; + enabled?: boolean; + config?: Record; +} + +export async function fetchIntegrations(): Promise { + return apiClient.local.json("/api/v1/integrations"); +} + +/** The S3 connections the caller may use, for source/output pickers. */ +export async function fetchS3Connections(): Promise { + return (await fetchIntegrations()).filter( + (integration) => integration.integrationType === "S3", + ); +} + +export async function createIntegration( + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json("/api/v1/integrations", { + method: "POST", + body, + }); +} + +export async function updateIntegration( + id: number, + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "PUT", body }, + ); +} + +export async function deleteIntegration(id: number): Promise { + await apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); +} diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx index 240d61f782..2200237cf6 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx @@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { - -

- } - > -
    - {steps.map((id, i) => ( -
  1. - - {i < stepIndex ? "✓" : i + 1} - - {stepLabels[id]} -
  2. - ))} -
- - {stepId === "type" && ( -
- {OFFERED_TYPES.map((ct) => ( - - ))} -
- )} - - {stepId === "configure" && ( -
- - setName(e.target.value)} - /> - - {type.fields.map((field) => ( - - {field.control === "select" ? ( - - setOptions((o) => ({ ...o, [field.key]: e.target.value })) - } - /> - )} - - ))} -
- )} - - {stepId === "review" && ( -
-
- - - {type.fields.map((field) => ( - - ))} -
- {error && } -
- )} - - ); -} diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx new file mode 100644 index 0000000000..938dfa832b --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { HttpError } from "@portal/api/http"; +import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; +import type { IntegrationConfig } from "@portal/api/integrations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchS3Connections = vi.fn(); +const deleteIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + deleteIntegration: (id: number) => deleteIntegration(id), + createIntegration: vi.fn(), + updateIntegration: vi.fn(), +})); + +const CONNECTION = { + id: 5, + integrationType: "S3", + name: "Claims bucket", + config: { bucket: "inbox", region: "us-east-1" }, + canManage: true, +} as unknown as IntegrationConfig; + +describe("ConnectionsTab", () => { + beforeEach(() => { + fetchS3Connections.mockReset(); + deleteIntegration.mockReset(); + deleteIntegration.mockResolvedValue(undefined); + }); + + it("shows the empty state when there are no connections", async () => { + fetchS3Connections.mockResolvedValue([]); + render(); + expect( + await screen.findByText("portal.connections.empty.title"), + ).toBeInTheDocument(); + }); + + it("lists connections and deletes one", async () => { + fetchS3Connections.mockResolvedValueOnce([CONNECTION]); + fetchS3Connections.mockResolvedValueOnce([]); + render(); + + expect(await screen.findByText("Claims bucket")).toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.connections.delete")); + await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); + }); + + it("surfaces the 409 when deleting a connection still in use", async () => { + fetchS3Connections.mockResolvedValue([CONNECTION]); + deleteIntegration.mockRejectedValue( + new HttpError(409, "Conflict", { + detail: "Integration is in use by: source 'Claims intake'", + }), + ); + render(); + + await screen.findByText("Claims bucket"); + fireEvent.click(screen.getByText("portal.connections.delete")); + expect( + await screen.findByText( + "Integration is in use by: source 'Claims intake'", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx new file mode 100644 index 0000000000..136f557032 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { + Banner, + Button, + EmptyState, + Skeleton, + Table, + type TableColumn, +} from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + deleteIntegration, + fetchS3Connections, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { SourcesIcon } from "@portal/components/icons"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; + +/** + * The Connections tab of the Sources page: stored S3 connections that sources + * and pipeline outputs reference by id. Create/edit go through the shared + * {@link S3ConnectionModal}; deleting one the backend still references returns a + * 409, surfaced inline. + */ +export function ConnectionsTab() { + const { t } = useTranslation(); + const [connections, setConnections] = useState( + null, + ); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setConnections(await fetchS3Connections()); + } catch (e) { + setError(errorMessage(e)); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + function openCreate() { + setEditing(null); + setModalOpen(true); + } + + function openEdit(connection: IntegrationConfig) { + setEditing(connection); + setModalOpen(true); + } + + async function remove(connection: IntegrationConfig) { + if (busy) return; + setBusy(true); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + + const columns = useMemo[]>( + () => [ + { + key: "name", + header: t("portal.connections.table.name"), + render: (c) => {c.name}, + }, + { + key: "bucket", + header: t("portal.connections.table.bucket"), + render: (c) => ( + + {String(c.config?.bucket ?? "")} + + ), + }, + { + key: "region", + header: t("portal.connections.table.region"), + render: (c) => String(c.config?.region ?? ""), + }, + { + key: "actions", + header: "", + align: "right", + render: (c) => + c.canManage ? ( + + + + + ) : null, + }, + ], + // remove/openEdit are stable enough for this admin surface; busy gates them. + [t, busy], + ); + + const isLoading = connections === null; + const isEmpty = connections !== null && connections.length === 0; + + return ( +
+
+

+ {t("portal.connections.subtitle")} +

+ +
+ + {error && } + + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {isEmpty && ( + } + title={t("portal.connections.empty.title")} + description={t("portal.connections.empty.description")} + actions={ + + } + /> + )} + + {connections !== null && connections.length > 0 && ( + + className="portal-sources__connections-table" + columns={columns} + rows={connections} + rowKey={(c) => String(c.id)} + /> + )} + + setModalOpen(false)} + onSaved={() => void refresh()} + /> +
+ ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx new file mode 100644 index 0000000000..0158025d83 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from "react-i18next"; +import { FormField, Input } from "@app/ui"; + +/** + * The connection-level S3 fields (per-use settings like prefix/mode live on the + * source or output referencing the connection). Secrets are write-only: when + * editing, the backend returns them masked and keeps the stored value if the + * mask is sent back unchanged. + */ +export interface S3ConnectionFormValues { + name: string; + bucket: string; + region: string; + endpoint: string; + accessKeyId: string; + secretAccessKey: string; +} + +export const EMPTY_S3_CONNECTION: S3ConnectionFormValues = { + name: "", + bucket: "", + region: "us-east-1", + endpoint: "", + accessKeyId: "", + secretAccessKey: "", +}; + +export function s3ConnectionRequestConfig( + values: S3ConnectionFormValues, +): Record { + return { + bucket: values.bucket.trim(), + region: values.region.trim(), + endpoint: values.endpoint.trim(), + accessKeyId: values.accessKeyId.trim(), + secretAccessKey: values.secretAccessKey, + }; +} + +export function s3ConnectionFormValid(values: S3ConnectionFormValues): boolean { + return ( + values.name.trim() !== "" && + values.bucket.trim() !== "" && + values.accessKeyId.trim() !== "" && + values.secretAccessKey.trim() !== "" + ); +} + +interface S3ConnectionFormProps { + values: S3ConnectionFormValues; + onChange: (values: S3ConnectionFormValues) => void; +} + +export function S3ConnectionForm({ values, onChange }: S3ConnectionFormProps) { + const { t } = useTranslation(); + const set = (key: keyof S3ConnectionFormValues, value: string) => + onChange({ ...values, [key]: value }); + + return ( +
+ + set("name", e.target.value)} + /> + + + set("bucket", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("accessKeyId", e.target.value)} + /> + + + set("secretAccessKey", e.target.value)} + /> + + + set("endpoint", e.target.value)} + /> + +
+ ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx new file mode 100644 index 0000000000..e052243fe9 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; +import type { IntegrationConfig } from "@portal/api/integrations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const createIntegration = vi.fn(); +const updateIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + createIntegration: (...a: unknown[]) => createIntegration(...a), + updateIntegration: (...a: unknown[]) => updateIntegration(...a), +})); + +function setField(labelPattern: RegExp, value: string) { + fireEvent.change(screen.getByLabelText(labelPattern), { target: { value } }); +} + +describe("S3ConnectionModal", () => { + beforeEach(() => { + createIntegration.mockReset(); + updateIntegration.mockReset(); + }); + + it("creates a team-scoped connection from the entered fields", async () => { + createIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" }); + const onSaved = vi.fn(); + const onClose = vi.fn(); + render(); + + setField(/portal\.connections\.s3\.fields\.name/, "Claims bucket"); + setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox"); + setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA"); + setField( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + "shh", + ); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); + expect(createIntegration).toHaveBeenCalledWith({ + integrationType: "S3", + name: "Claims bucket", + scope: "TEAM", + config: { + bucket: "inbox", + region: "us-east-1", + endpoint: "", + accessKeyId: "AKIA", + secretAccessKey: "shh", + }, + }); + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalled(); + }); + + it("round-trips a masked secret unchanged on edit (keeps the stored value)", async () => { + updateIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" }); + // The API returns secrets masked; the modal must resend the sentinel verbatim + // so the backend keeps the stored secret rather than overwriting it. + const connection = { + id: 5, + integrationType: "S3", + name: "Claims bucket", + config: { + bucket: "inbox", + region: "us-east-1", + accessKeyId: "AKIA", + secretAccessKey: "********", + }, + canManage: true, + } as unknown as IntegrationConfig; + + render( + , + ); + + const secret = screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + ) as HTMLInputElement; + expect(secret.value).toBe("********"); + // Change only the name; leave the masked secret untouched. + setField(/portal\.connections\.s3\.fields\.name/, "Renamed bucket"); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(updateIntegration).toHaveBeenCalledTimes(1)); + expect(updateIntegration).toHaveBeenCalledWith( + 5, + expect.objectContaining({ + name: "Renamed bucket", + config: expect.objectContaining({ secretAccessKey: "********" }), + }), + ); + }); + + it("keeps save disabled until the required fields are present", () => { + render(); + const save = () => + screen.getByText("portal.connections.picker.save").closest("button"); + + expect(save()).toBeDisabled(); + setField(/portal\.connections\.s3\.fields\.name/, "Only a name"); + expect(save()).toBeDisabled(); + setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox"); + setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA"); + setField( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + "shh", + ); + expect(save()).not.toBeDisabled(); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx new file mode 100644 index 0000000000..c4e649df4e --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Modal } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + createIntegration, + updateIntegration, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { + EMPTY_S3_CONNECTION, + S3ConnectionForm, + s3ConnectionFormValid, + s3ConnectionRequestConfig, + type S3ConnectionFormValues, +} from "@portal/components/sources/S3ConnectionForm"; + +/** + * The one place S3 connections are created and edited. Launched from the + * Connections tab, the source builder's connection picker, and the pipeline + * builder output - so connection setup is always a modal, never inline splat. + * Saving validates backend-side (schema, SSRF, credentials); on edit the secret + * arrives masked and round-trips unchanged to keep the stored value. + */ +interface S3ConnectionModalProps { + open: boolean; + /** When set, edit this connection; otherwise create a new one. */ + connection?: IntegrationConfig | null; + onClose: () => void; + /** The saved connection, so callers can select or refresh it. */ + onSaved: (connection: IntegrationConfig) => void; +} + +export function S3ConnectionModal({ + open, + connection, + onClose, + onSaved, +}: S3ConnectionModalProps) { + const { t } = useTranslation(); + const [form, setForm] = useState(EMPTY_S3_CONNECTION); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const isEdit = Boolean(connection); + + // Seed the form each time the modal opens (or its target changes). + useEffect(() => { + if (!open) return; + if (connection) { + const config = connection.config ?? {}; + setForm({ + name: connection.name, + bucket: String(config.bucket ?? ""), + region: String(config.region ?? "us-east-1"), + endpoint: String(config.endpoint ?? ""), + accessKeyId: String(config.accessKeyId ?? ""), + secretAccessKey: String(config.secretAccessKey ?? ""), + }); + } else { + setForm(EMPTY_S3_CONNECTION); + } + setError(null); + }, [open, connection]); + + async function save() { + if (saving || !s3ConnectionFormValid(form)) return; + setSaving(true); + setError(null); + try { + const saved = connection + ? await updateIntegration(connection.id, { + name: form.name.trim(), + config: s3ConnectionRequestConfig(form), + }) + : // TEAM scope suits the team-based portal (the backend defaults the team to the + // caller's own). A teamless single-operator self-hosted deployment would need a + // USER/SERVER scope choice here - follow-up if the portal ships there. + await createIntegration({ + integrationType: "S3", + name: form.name.trim(), + scope: "TEAM", + config: s3ConnectionRequestConfig(form), + }); + onSaved(saved); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSaving(false); + } + } + + return ( + + + +
+ } + > + + {error && } + + ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx new file mode 100644 index 0000000000..8800fdfd15 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchS3Connections = vi.fn(); +const createIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + createIntegration: (...a: unknown[]) => createIntegration(...a), + updateIntegration: vi.fn(), +})); + +describe("S3ConnectionPicker", () => { + beforeEach(() => { + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); + createIntegration.mockReset(); + }); + + it("creates a connection inline and selects it", async () => { + createIntegration.mockResolvedValue({ id: 7, name: "New bucket" }); + const onChange = vi.fn(); + render(); + + fireEvent.click( + await screen.findByText("portal.connections.picker.createNew"), + ); + fireEvent.change( + screen.getByLabelText(/portal\.connections\.s3\.fields\.name/), + { target: { value: "New bucket" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.bucket\.label/, + ), + { target: { value: "inbox" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, + ), + { target: { value: "AKIA" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + ), + { target: { value: "shh" } }, + ); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); + // The newly created connection's id is selected in the parent. + await waitFor(() => expect(onChange).toHaveBeenCalledWith("7")); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx new file mode 100644 index 0000000000..58b559e23b --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Select } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + fetchS3Connections, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; + +/** + * Selects a stored S3 connection by id. Creating a new one opens the shared + * connection modal (saved immediately and validated backend-side), so the + * parent only ever sees a real connection id. + */ +interface S3ConnectionPickerProps { + value: string; + onChange: (connectionId: string) => void; +} + +export function S3ConnectionPicker({ + value, + onChange, +}: S3ConnectionPickerProps) { + const { t } = useTranslation(); + const [connections, setConnections] = useState( + null, + ); + const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + fetchS3Connections() + .then((list) => { + if (mounted) setConnections(list); + }) + .catch((e) => { + if (mounted) setError(errorMessage(e)); + }); + return () => { + mounted = false; + }; + }, []); + + return ( +
+ + setOutputS3((s) => ({ ...s, prefix: e.target.value })) + } + /> + + )}
@@ -1006,78 +991,6 @@ export function PipelineBuilder() { >

{t("portal.pipelines.builder.unsavedBody")}

- - setS3ConfigOpen(false)} - title={t("portal.pipelines.composer.s3ModalTitle")} - footer={ -
- -
- } - > -
- - setS3Field("bucket", e.target.value)} - /> - - - setS3Field("region", e.target.value)} - /> - - - setS3Field("prefix", e.target.value)} - /> - - - setS3Field("accessKeyId", e.target.value)} - /> - - - setS3Field("secretAccessKey", e.target.value)} - /> - - - setS3Field("endpoint", e.target.value)} - /> - -
-
); } diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index e2345f62a6..23a8da75da 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -30,7 +30,7 @@ export function Pipelines() { const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`); + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); // A row opens that pipeline's own page (view / edit / run / delete live there). const openPipeline = (pipeline: PipelineView) => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); diff --git a/frontend/editor/src/portal/views/SourceBuilder.css b/frontend/editor/src/portal/views/SourceBuilder.css new file mode 100644 index 0000000000..985332046d --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.css @@ -0,0 +1,87 @@ +.portal-source-builder { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +.portal-source-builder__loading { + display: flex; + justify-content: center; + padding: 4rem 0; +} + +.portal-source-builder__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-source-builder__head-main { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.portal-source-builder__title { + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); + margin: 0; +} + +.portal-source-builder__head-actions { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.portal-source-builder__body { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 32rem; +} + +.portal-source-builder__type-grid { + display: flex; + gap: 0.625rem; + flex-wrap: wrap; +} + +.portal-source-builder__type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + min-width: 6rem; + padding: 0.875rem 1rem; + border: 1px solid var(--color-border-2); + border-radius: 0.5rem; +} + +.portal-source-builder__type-card.is-selected { + border-color: var(--color-accent, var(--color-brand)); + background: var(--color-bg-hover); +} + +.portal-source-builder__type-icon { + font-size: 1.5rem; + line-height: 1; +} + +.portal-source-builder__type-name { + font-size: 0.8125rem; + font-weight: 500; +} + +.portal-source-builder__delete-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx new file mode 100644 index 0000000000..1f9b3341f8 --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { SourceBuilder } from "@portal/views/SourceBuilder"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const createSource = vi.fn(); +const fetchSource = vi.fn(); +const deleteSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + createSource: (s: unknown) => createSource(s), + fetchSource: (id: string) => fetchSource(id), + deleteSource: (id: string) => deleteSource(id), +})); + +const fetchS3Connections = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + createIntegration: vi.fn(), +})); + +function renderBuilder(initial: string) { + return render( + + + sources list} /> + } /> + } /> + + , + ); +} + +describe("SourceBuilder", () => { + beforeEach(() => { + createSource.mockReset(); + createSource.mockResolvedValue({ id: "src-1" }); + fetchSource.mockReset(); + deleteSource.mockReset(); + deleteSource.mockResolvedValue(undefined); + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); + }); + + it("creates a folder source and returns to the list", async () => { + renderBuilder("/processor/sources/new"); + + // Folder is the first offered type; fill name + directory. + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Claims intake" }, + }); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ), + { target: { value: "/data/incoming" } }, + ); + fireEvent.click(screen.getByText("portal.sources.builder.create")); + + await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Claims intake", + type: "folder", + options: expect.objectContaining({ directory: "/data/incoming" }), + enabled: true, + }), + ); + expect(await screen.findByText("sources list")).toBeInTheDocument(); + }); + + it("gates the s3 type on a chosen connection", async () => { + renderBuilder("/processor/sources/new"); + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Bucket source" }, + }); + // Switch to the S3 type: the connection field appears and Create stays + // disabled until a connection is chosen (connectionId is required). + fireEvent.click(screen.getByText("portal.sources.types.s3.label")); + expect( + await screen.findByText( + "portal.sources.types.s3.fields.connection.label", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.sources.builder.create").closest("button"), + ).toBeDisabled(); + }); + + it("blocks create until required fields are filled", async () => { + renderBuilder("/processor/sources/new"); + // Name given but directory (required) still blank -> Create disabled. + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Nameonly" }, + }); + expect( + screen.getByText("portal.sources.builder.create").closest("button"), + ).toBeDisabled(); + }); + + it("edits an existing source prefilled and saves with its id", async () => { + fetchSource.mockResolvedValue({ + id: "src-9", + name: "Existing", + type: "folder", + options: { directory: "/old", mode: "consume" }, + enabled: true, + }); + renderBuilder("/processor/sources/src-9"); + + const directory = await screen.findByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ); + expect((directory as HTMLInputElement).value).toBe("/old"); + fireEvent.change(directory, { target: { value: "/new" } }); + fireEvent.click(screen.getByText("portal.sources.builder.save")); + + await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ + id: "src-9", + options: expect.objectContaining({ directory: "/new" }), + }), + ); + }); + + it("deletes an existing source after confirmation", async () => { + fetchSource.mockResolvedValue({ + id: "src-9", + name: "Existing", + type: "folder", + options: { directory: "/old" }, + enabled: true, + }); + renderBuilder("/processor/sources/src-9"); + + fireEvent.click(await screen.findByText("portal.sources.builder.delete")); + fireEvent.click(await screen.findByText("portal.sources.delete.confirm")); + + await waitFor(() => expect(deleteSource).toHaveBeenCalledWith("src-9")); + expect(await screen.findByText("sources list")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx new file mode 100644 index 0000000000..31b8550354 --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import { + Banner, + Button, + Checkbox, + FormField, + Input, + Modal, + Select, + Spinner, +} from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + createSource, + deleteSource, + fetchSource, + type Source, +} from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; +import { + CREATABLE_SOURCE_TYPES, + defaultOptions, + sourceTypeMeta, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; +import "@portal/views/SourceBuilder.css"; + +const OFFERED_TYPES = creatableSourceTypes(); + +/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */ +function typeFor(type: string | undefined): CreatableSourceType { + return ( + CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? + OFFERED_TYPES[0] ?? + CREATABLE_SOURCE_TYPES[0] + ); +} + +/** Stored options coerced to form strings, defaulted from the type's fields. */ +function optionsFor( + type: CreatableSourceType, + options: Record | undefined, +): Record { + const out = defaultOptions(type); + for (const [key, value] of Object.entries(options ?? {})) { + out[key] = value == null ? "" : String(value); + } + return out; +} + +/** + * Full-page create/edit for a source, mirroring the pipeline builder: new lands + * on /sources/new (with a type picker), a row opens /sources/:id prefilled. + * Save and delete navigate back to the Sources list. The virtual editor source + * is never routed here (the list row is not a link). + */ +export function SourceBuilder() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = Boolean(id); + const listPath = toPortalPath(VIEW_PATHS.sources); + + const sourceState = useAsync( + async () => (id ? await fetchSource(id) : null), + [id], + ); + + const [type, setType] = useState(OFFERED_TYPES[0]); + const [name, setName] = useState(""); + const [options, setOptions] = useState>(() => + defaultOptions(OFFERED_TYPES[0]), + ); + const [enabled, setEnabled] = useState(true); + const [seeded, setSeeded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + // Seed once: immediately for a new source, or after the record loads for edit. + useEffect(() => { + if (seeded) return; + if (isEdit && !sourceState.data) return; + const source = sourceState.data ?? undefined; + const resolved = typeFor(source?.type); + setType(resolved); + setName(source?.name ?? ""); + setOptions(optionsFor(resolved, source?.options)); + setEnabled(source?.enabled ?? true); + setSeeded(true); + }, [isEdit, sourceState.data, seeded]); + + function chooseType(next: CreatableSourceType) { + setType(next); + setOptions(defaultOptions(next)); + } + + function setOption(key: string, value: string) { + setOptions((current) => ({ ...current, [key]: value })); + } + + const requiredComplete = type.fields.every( + (field) => !field.required || (options[field.key] ?? "").trim() !== "", + ); + const canSave = name.trim() !== "" && requiredComplete && !submitting; + + async function save() { + if (!canSave) return; + setSubmitting(true); + setError(null); + try { + await createSource({ + id: isEdit ? id : undefined, + name: name.trim(), + type: type.type, + options, + enabled, + }); + navigate(listPath); + } catch (e) { + setError(errorMessage(e)); + setSubmitting(false); + } + } + + async function confirmDelete() { + if (!id || deleting) return; + setDeleting(true); + try { + await deleteSource(id); + navigate(listPath); + } catch (e) { + setError(errorMessage(e)); + setDeleting(false); + setPendingDelete(false); + } + } + + if (isEdit && sourceState.error) { + return ( +
+ + +
+ ); + } + + if (isEdit && !seeded) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +

+ {isEdit + ? name || t("portal.sources.builder.editTitle") + : t("portal.sources.builder.createTitle")} +

+
+
+ setEnabled(e.target.checked)} + label={t("portal.sources.builder.enabled")} + /> + {isEdit && ( + + )} + + +
+
+ +
+ + setName(e.target.value)} + /> + + + {!isEdit && OFFERED_TYPES.length > 1 && ( + +
+ {OFFERED_TYPES.map((ct) => ( + + ))} +
+
+ )} + + {type.fields.map((field) => ( + + {field.control === "s3Connection" ? ( + setOption(field.key, connectionId)} + /> + ) : field.control === "select" ? ( + setOption(field.key, e.target.value)} + /> + )} + + ))} + + {error && } +
+ + !deleting && setPendingDelete(false)} + width="sm" + title={t("portal.sources.delete.title")} + footer={ +
+ + +
+ } + > +

{t("portal.sources.delete.body", { name })}

+
+
+ ); +} diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index d8cc6897af..54d8906be9 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -438,3 +438,101 @@ gap: 0.5rem; width: 100%; } + +.portal-sources__connection-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.portal-sources__connection-picker .sui-select, +.portal-sources__connection-picker > div:first-child { + align-self: stretch; +} + +.portal-sources__connection-create { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--color-border-2); + border-radius: 0.5rem; + align-self: stretch; +} + +.portal-sources__connection-create-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.portal-sources__connection-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-sources__connections { + margin-top: 1.5rem; +} + +.portal-sources__connections-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-2); + margin: 0 0 0.5rem; +} + +.portal-sources__connections-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-sources__connections-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.375rem 0; + border-bottom: 1px solid var(--color-border-2); +} + +.portal-sources__connections-name { + font-weight: 500; + color: var(--color-text-1); +} + +.portal-sources__connections-bucket { + color: var(--color-text-4); + font-size: 0.8125rem; +} + +.portal-sources__connections-actions { + margin-left: auto; + display: flex; + gap: 0.25rem; +} + +.portal-sources__connections-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.portal-sources__connections-sub { + color: var(--color-text-4); + font-size: 0.875rem; + margin: 0; +} + +.portal-sources__connections-actions { + display: inline-flex; + gap: 0.25rem; + justify-content: flex-end; +} diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 5e281d5604..a006cbe4a2 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -3,21 +3,16 @@ import { fireEvent, render as baseRender, screen, - waitFor, } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; -import { MemoryRouter } from "react-router-dom"; -import { HttpError } from "@portal/api/http"; - -const render = ( - ui: Parameters[0], - options?: Parameters[1], -) => baseRender(ui, { wrapper: MantineProvider, ...options }); +import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { SourcesResponse } from "@portal/api/sources"; import { Sources } from "@portal/views/Sources"; -// Deterministic i18n: keys returned verbatim, so assertions are stable without -// the async TOML backend. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +// Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -26,48 +21,48 @@ vi.mock("react-i18next", () => ({ })); const fetchSources = vi.fn(); -const fetchSource = vi.fn(); -const fetchSourceDocCounts = vi.fn(); -const createSource = vi.fn(); -const deleteSource = vi.fn(); vi.mock("@portal/api/sources", () => ({ fetchSources: () => fetchSources(), - fetchSource: (id: string) => fetchSource(id), - fetchSourceDocCounts: (id: string) => fetchSourceDocCounts(id), - createSource: (source: unknown) => createSource(source), - deleteSource: (id: string) => deleteSource(id), +})); + +const fetchS3Connections = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + deleteIntegration: vi.fn(), +})); + +// The Agent Builder header action is a flavor seam; stub it to keep the test focused. +vi.mock("@portal/components/sources/AgentBuilderAction", () => ({ + AgentBuilderAction: () => null, })); const RESPONSE: SourcesResponse = { kpis: [ - { value: 2, description: "" }, { value: 1, description: "" }, { value: 1, description: "" }, + { value: 0, description: "" }, ], sources: [ { - id: "src-referenced", + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 5, + docs24h: 0, + docs30d: 5, + }, + { + id: "src-1", name: "Claims intake", type: "folder", status: "active", referenceCount: 2, - referencingPolicies: [ - { id: "pol-1", name: "Redaction" }, - { id: "pol-2", name: "Classification" }, - ], - config: [{ label: "Directory", value: "/data/incoming" }], - docsTotal: 1240, - docs24h: 18, - docs30d: 540, - }, - { - id: "src-orphan", - name: "Scratch folder", - type: "folder", - status: "unused", - referenceCount: 0, referencingPolicies: [], - config: [{ label: "Directory", value: "/tmp/scratch" }], + config: [{ label: "Directory", value: "/in" }], docsTotal: 1240, docs24h: 18, docs30d: 540, @@ -75,10 +70,20 @@ const RESPONSE: SourcesResponse = { ], }; -function renderView() { +function renderView(initial = "/processor/sources") { return render( - - + + + } /> + source builder: new} + /> + source builder: edit} + /> + , ); } @@ -86,122 +91,54 @@ function renderView() { describe("Sources view", () => { beforeEach(() => { fetchSources.mockReset(); - fetchSource.mockReset(); - fetchSourceDocCounts.mockReset(); - fetchSourceDocCounts.mockResolvedValue([]); - createSource.mockReset(); - deleteSource.mockReset(); - }); - - it("surfaces the inline 409 message when deleting a referenced source", async () => { fetchSources.mockResolvedValue(RESPONSE); - deleteSource.mockRejectedValue( - new HttpError(409, "Conflict", { - detail: "Source is referenced by 2 policies", - }), - ); - - renderView(); - - // Wait for the row to render after the async fetch resolves. - const row = await screen.findByText("Claims intake"); - fireEvent.click(row); - - // Detail card opens with its delete action. - fireEvent.click(await screen.findByText("portal.sources.detail.delete")); - - // Confirm in the dialog. - fireEvent.click(await screen.findByText("portal.sources.delete.confirm")); - - await waitFor(() => { - expect(deleteSource).toHaveBeenCalledWith("src-referenced"); - }); - - expect( - await screen.findByText("Source is referenced by 2 policies"), - ).toBeInTheDocument(); + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); }); - it("shows the editor as a built-in source with no edit, pause, or delete actions", async () => { - fetchSources.mockResolvedValue({ - kpis: [], - sources: [ - { - id: "editor", - name: "Editor", - type: "editor", - status: "active", - referenceCount: 1, - referencingPolicies: [{ id: "pol-1", name: "Redaction" }], - config: [], - docsTotal: 8230, - docs24h: 42, - docs30d: 1680, - }, - ], - } satisfies SourcesResponse); - + it("opens a source's own page on row click", async () => { renderView(); + fireEvent.click(await screen.findByText("Claims intake")); + expect(await screen.findByText("source builder: edit")).toBeInTheDocument(); + }); - // The editor row is labelled from its type (i18n keys are returned verbatim here). + it("navigates to the create page from the connect button", async () => { + renderView(); + await screen.findByText("Claims intake"); + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + expect(await screen.findByText("source builder: new")).toBeInTheDocument(); + }); + + it("does not navigate when the virtual editor row is clicked", async () => { + renderView(); fireEvent.click( await screen.findByText("portal.sources.types.editor.label"), ); - - // Detail opens, but none of the mutate actions are offered for the built-in source. - await screen.findByText("portal.sources.detail.documents"); - expect(screen.queryByText("portal.sources.detail.edit")).toBeNull(); - expect(screen.queryByText("portal.sources.detail.pause")).toBeNull(); - expect(screen.queryByText("portal.sources.detail.delete")).toBeNull(); + // Still on the list: the builder stub never rendered. + expect(screen.queryByText("source builder: edit")).not.toBeInTheDocument(); + expect(screen.getByText("Claims intake")).toBeInTheDocument(); }); - it("pauses a source by re-saving it with enabled flipped off", async () => { - fetchSources.mockResolvedValue(RESPONSE); - fetchSource.mockResolvedValue({ - id: "src-referenced", - name: "Claims intake", - type: "folder", - options: { directory: "/data/incoming", mode: "consume" }, - enabled: true, - }); - createSource.mockResolvedValue({}); - - renderView(); - - fireEvent.click(await screen.findByText("Claims intake")); - fireEvent.click(await screen.findByText("portal.sources.detail.pause")); - - await waitFor(() => { - expect(createSource).toHaveBeenCalledTimes(1); - }); - expect(fetchSource).toHaveBeenCalledWith("src-referenced"); - expect(createSource).toHaveBeenCalledWith( - expect.objectContaining({ id: "src-referenced", enabled: false }), - ); - }); - - it("shows the KPI stat boxes when sources exist", async () => { - fetchSources.mockResolvedValue(RESPONSE); + it("shows the connections surface on the Connections tab", async () => { renderView(); await screen.findByText("Claims intake"); - expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.sources.tabs.connections")); + // Empty connections list -> the connections empty state. + expect( + await screen.findByText("portal.connections.empty.title"), + ).toBeInTheDocument(); + expect(fetchS3Connections).toHaveBeenCalled(); }); - it("hides the stat boxes and shows the connect CTA when empty", async () => { + it("hides the KPI strip and shows the empty state when only the editor exists", async () => { fetchSources.mockResolvedValue({ - kpis: [ - { value: 0, description: "" }, - { value: 0, description: "" }, - { value: 0, description: "" }, - ], - sources: [], + kpis: RESPONSE.kpis, + sources: [RESPONSE.sources[0]], }); renderView(); - // The empty-state panel renders. expect( await screen.findByText("portal.sources.empty.title"), ).toBeInTheDocument(); - // The KPI strip is gone: no stat-box labels over an empty page. expect( screen.queryByText("portal.sources.kpi.total"), ).not.toBeInTheDocument(); diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 60f9d628f9..fb0b14ccf5 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,137 +1,49 @@ -import { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button, EmptyState, Skeleton, Tabs } from "@app/ui"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { SourcesIcon } from "@portal/components/icons"; -import { errorMessage } from "@portal/api/http"; import { - createSource, - deleteSource, - fetchSource, - fetchSourceDocCounts, fetchSources, - type Source, type SourcesResponse, type SourceView, } from "@portal/api/sources"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; -import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; -import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; +import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; import "@portal/views/Sources.css"; +type SourcesTab = "sources" | "connections"; + export function Sources() { const { t } = useTranslation(); + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - // Refetch after every mutation by bumping this counter, so the table reflects - // the in-memory store the handlers maintain (mirrors the Policies view). - const [version, setVersion] = useState(0); - const state = useAsync(() => fetchSources(), [version]); + const activeTab: SourcesTab = + searchParams.get("tab") === "connections" ? "connections" : "sources"; + + const state = useAsync(() => fetchSources(), []); const { data, loading } = state; const { isLoading } = useSectionFlags(state); - const refetch = useCallback(() => setVersion((v) => v + 1), []); - - const [expandedId, setExpandedId] = useState(null); - const [wizardOpen, setWizardOpen] = useState(false); - const [editingSource, setEditingSource] = useState(null); - const [mutating, setMutating] = useState(false); - const [pageError, setPageError] = useState(null); - const [pendingDelete, setPendingDelete] = useState(null); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); const sources = data?.sources ?? []; - const expanded = sources.find((s) => s.id === expandedId) ?? null; - // Empty once the fetch settles with no sources (or fails → no data). Gates - // both the KPI strip and the empty panel so no placeholder stat boxes sit - // above an empty page. - const showEmpty = !isLoading && sources.length === 0; + // The editor is a virtual row that's always present, so "empty" means no + // configured sources beyond it. Gates the KPI strip and empty panel. + const configuredCount = sources.filter((s) => s.type !== "editor").length; + const showEmpty = !isLoading && configuredCount === 0; - // The 30-day sparkline series lives off the list endpoint; fetch it for the one - // expanded row only (empty while collapsed, so no request fires). - const docSeriesState = useAsync<{ id: string; series: number[] }>( - () => - expandedId - ? fetchSourceDocCounts(expandedId).then((series) => ({ - id: expandedId, - series, - })) - : Promise.resolve({ id: "", series: [] }), - [expandedId], - ); - const docSeries = - docSeriesState.data?.id === expandedId ? docSeriesState.data.series : []; + const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + const openSource = (source: SourceView) => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/${source.id}`); - function openCreate() { - setEditingSource(null); - setWizardOpen(true); - } - - // Arriving with ?new (e.g. from the pipeline builder's "connect a source" link) opens the - // create wizard straight away, then strips the flag so a refresh doesn't reopen it. - useEffect(() => { - if (searchParams.get("new") === null) return; - setEditingSource(null); - setWizardOpen(true); + function selectTab(tab: SourcesTab) { const next = new URLSearchParams(searchParams); - next.delete("new"); + if (tab === "sources") next.delete("tab"); + else next.set("tab", tab); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); - - // Editing needs the raw source (config options), which the overview rows don't - // carry, so fetch it before opening the wizard prefilled. - async function openEdit(source: SourceView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - setEditingSource(await fetchSource(source.id)); - setWizardOpen(true); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - // Pause/resume: re-save the source with enabled flipped (same POST contract as - // edit). Fetch the raw record first so the full config round-trips intact. - async function togglePause(source: SourceView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - const raw = await fetchSource(source.id); - await createSource({ ...raw, enabled: !raw.enabled }); - refetch(); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - function requestDelete(source: SourceView) { - setDeleteError(null); - setPendingDelete(source); - } - - async function confirmDelete() { - if (!pendingDelete || deleting) return; - setDeleting(true); - setDeleteError(null); - try { - await deleteSource(pendingDelete.id); - setPendingDelete(null); - setExpandedId(null); - refetch(); - } catch (e) { - setDeleteError(errorMessage(e)); - } finally { - setDeleting(false); - } } return ( @@ -141,102 +53,67 @@ export function Sources() {

{t("portal.sources.title")}

{t("portal.sources.subtitle")}

-
- - -
- - - {pageError && } - - {!showEmpty && } - - {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- )} - - {showEmpty && ( - } - title={t("portal.sources.empty.title")} - description={t("portal.sources.empty.description")} - actions={ + {activeTab === "sources" && ( +
+ - } - /> - )} +
+ )} + - {!isLoading && sources.length > 0 && ( - - setExpandedId((cur) => (cur === s.id ? null : s.id)) - } - /> - )} - - {expanded && ( - setExpandedId(null)} - onEdit={openEdit} - onTogglePause={togglePause} - onDelete={requestDelete} - busy={mutating} - /> - )} - - setWizardOpen(false)} - onCreated={refetch} + + variant="underline" + ariaLabel={t("portal.sources.title")} + activeKey={activeTab} + onChange={selectTab} + items={[ + { key: "sources", label: t("portal.sources.tabs.sources") }, + { key: "connections", label: t("portal.sources.tabs.connections") }, + ]} /> - !deleting && setPendingDelete(null)} - width="sm" - title={t("portal.sources.delete.title")} - footer={ -
- - -
- } - > -

- {t("portal.sources.delete.body", { name: pendingDelete?.name ?? "" })} -

- {deleteError && } -
+ {activeTab === "connections" ? ( + + ) : ( + <> + {!showEmpty && } + + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {showEmpty && ( + } + title={t("portal.sources.empty.title")} + description={t("portal.sources.empty.description")} + actions={ + + } + /> + )} + + {!isLoading && sources.length > 0 && ( + + )} + + )} ); } From a1b15e0570db4826cd3db4a4df12613e4a53707c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:00:01 +0100 Subject: [PATCH 016/109] Portal: dark disabled buttons and role column width (#7004) # Description of Changes Fixes disabled buttons rendering as plain grey in dark mode, and widens the Users role column so "Organisation Owner" no longer clips. Part of a portal (processor) UI-consistency pass, split into small focused PRs. ## Before / after --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/editor/src/core/ui/Button.css | 15 +++++++++------ frontend/editor/src/portal/views/Users.css | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index e80bf13222..6ec23ca9ea 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -61,11 +61,14 @@ background: var(--button-bg, transparent) !important; } +/* Disabled buttons in dark read as a muted surface (not a dimmed accent that + still looks clickable, and not an invisible transparent pill). Covers every + variant so a disabled primary and a disabled secondary look alike. */ +[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]), [data-theme="dark"] - .sui-btn--primary.mantine-Button-root:disabled:not([data-loading]), -[data-theme="dark"] - .sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) { - background: var(--button-bg); - color: var(--button-color); - opacity: 0.55; + .sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) { + background: var(--color-bg-muted); + color: var(--color-text-5); + border-color: transparent; + opacity: 1; } diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index f351ebeaa3..dfaa9284a6 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -396,7 +396,7 @@ white-space: nowrap; } .portal-users__row-role { - width: 148px; + width: 12.5rem; flex-shrink: 0; } From 4d4e99456283ff6f4806b5d9834da638110aa6d6 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 14 Jul 2026 10:09:23 +0100 Subject: [PATCH 017/109] Fix crash in Processor when loading tool settings with tooltips (#7015) # Description of Changes Some of the tool settings make use of editor preferences indirectly, but the Processor never gets that provider, so it crashes when trying to load them. --- .../pipelines/PipelineStepSettings.test.tsx | 52 +++++++++++++++++++ .../pipelines/PipelineStepSettings.tsx | 24 +++++---- 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx new file mode 100644 index 0000000000..698fb0028a --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.test.tsx @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { ToolRegistry } from "@app/data/toolsTaxonomy"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip +// pulls in the Preferences + Sidebar contexts, which the portal does not mount +// app-wide — so this reproduces the "usePreferences must be used within a +// PreferencesProvider" crash unless PipelineStepSettings supplies them. +function TooltipSettings() { + return ( + + + + ); +} + +const step = { + support: "editable", + toolId: "compress", + params: {}, +} as unknown as WorkingToolStep; + +const registry = { + compress: { automationSettings: TooltipSettings }, +} as unknown as Partial; + +describe("PipelineStepSettings", () => { + it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => { + expect(() => + render( + + {}} + /> + , + ), + ).not.toThrow(); + expect(screen.getByText("field")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx index fe2ee9dd2a..672383ecd3 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx @@ -1,6 +1,8 @@ import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { Banner } from "@app/ui"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { SidebarProvider } from "@app/contexts/SidebarContext"; import { type ToolRegistry } from "@app/data/toolsTaxonomy"; import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; @@ -46,14 +48,18 @@ export function PipelineStepSettings({ } return ( - - - onChange({ ...step.params, [key]: value }) - } - disabled={false} - /> - + + + + + onChange({ ...step.params, [key]: value }) + } + disabled={false} + /> + + + ); } From 41b1b89fcb680ec4976215c3430eab14eb4a63da Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 14 Jul 2026 10:43:07 +0100 Subject: [PATCH 018/109] Fix Policies page showing the Editor as a source twice (#7022) # Description of Changes The Policies page currently hard-codes the Editor to be available as a source, but we now also have a virtual Editor source on the backend, which the Policies page also renders. This removes the now-unnecessary hard-coded Editor source. ## Before image ## After image --- .../components/policies/PolicySetupWizard.tsx | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 9dada09290..9dce344b89 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -213,22 +213,11 @@ function PolicySetupWizardBody({ ); const sourcesAsync = useAsync(() => fetchSources(), []); - const availableSources = useMemo(() => { - const backendSources = (sourcesAsync.data?.sources ?? []).filter( - (s) => s.status !== "disabled", - ); - const editorSource = { - id: "editor", - name: t("portal.sources.types.editor.label"), - type: "editor", - status: "active" as const, - referenceCount: 0, - referencingPolicies: [], - config: [], - docsTotal: null, - }; - return [editorSource, ...backendSources]; - }, [sourcesAsync.data, t]); + const availableSources = useMemo( + () => + (sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"), + [sourcesAsync.data], + ); // Document-type scoping has no UI; preserve any saved scope on edit and // default new policies to all document types. const [scopeTypes] = useState(policy?.state.scopeTypes ?? []); @@ -475,9 +464,8 @@ function PolicySetupWizardBody({ {t("portal.policies.wizard.sources.loading")}

) : ( - // The editor is always an available source (unconditionally prepended - // to availableSources), so the list is never empty — no "no sources" - // state exists. + // The backend always returns the editor as a virtual source, so the + // loaded list is never empty - no "no sources" state exists.
{availableSources.map((src) => ( // A selectable multi-line tile (icon + name + type + check). From 776749277cfb64bc39e95420e526f84649c588e1 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 14 Jul 2026 10:58:04 +0100 Subject: [PATCH 019/109] Redesign policies to use typed mappings properly (#7017) # Description of Changes The Policies page and all the frontend logic for running Policies is not making use of the bidirectional type mappings that we now have to safely convert from frontend to backend param models and vice versa. This changes the way we track the types throughout so we use the mappings properly. Because of this, the Add Watermark settings in Policies now actually pre-populate with the defaults instead of with nothing like they previously did. image --- .../hooks/tools/shared/toolAutomation.test.ts | 36 ----- .../core/hooks/tools/shared/toolAutomation.ts | 25 ---- .../shared/toolOperationDescriptor.test.ts | 37 +++++ .../tools/shared/toolOperationDescriptor.ts | 59 ++++++++ frontend/editor/src/portal/api/policies.ts | 72 +++------- .../policies/PolicySetupWizard.test.tsx | 134 +++++++++++++++++ .../components/policies/PolicySetupWizard.tsx | 135 ++++++++---------- frontend/editor/src/portal/mocks/policies.ts | 26 +++- .../components/policies/PolicyPiiField.tsx | 9 +- .../policies/PolicyRedactConfig.tsx | 5 +- .../policies/PolicyWatermarkConfig.tsx | 8 +- .../proprietary/policies/operations.test.ts | 112 +++++++++++++++ .../src/proprietary/policies/operations.ts | 131 +++++++++++++++++ 13 files changed, 587 insertions(+), 202 deletions(-) create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts create mode 100644 frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx create mode 100644 frontend/editor/src/proprietary/policies/operations.test.ts create mode 100644 frontend/editor/src/proprietary/policies/operations.ts diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 7caf6ee120..03b85798d8 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -13,7 +13,6 @@ import { import { deserializeToolStep, getExecutableTools, - serializeStepFromEndpoint, serializeToolStep, stepRequiresUpload, type WorkingToolStep, @@ -199,41 +198,6 @@ describe("serialize/deserialize round-trip", () => { }); }); -describe("serializeStepFromEndpoint", () => { - test("maps a wizard step's UI params to the backend contract, filling defaults", () => { - // The shape the policy setup wizard holds: an endpoint plus UI-shaped params - // (redact's `wordsToRedact`), with several fields left to their defaults. - const api = serializeStepFromEndpoint( - "/api/v1/security/auto-redact", - { mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] }, - dynamicRegistry, - ); - - expect(api.operation).toBe("/api/v1/security/auto-redact"); - // wordsToRedact -> listOfText (the field the backend actually reads), and the - // frontend-only `mode` is dropped. - expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" }); - expect(api.parameters).not.toHaveProperty("wordsToRedact"); - expect(api.parameters).not.toHaveProperty("mode"); - // Fields the wizard never set still get their defaults so the body is complete. - expect(api.parameters).toHaveProperty("wholeWordSearch"); - expect(api.parameters).toHaveProperty("customPadding"); - }); - - test("passes an unmapped endpoint's params through unchanged", () => { - expect( - serializeStepFromEndpoint( - "/api/v1/unknown/thing", - { keep: true }, - dynamicRegistry, - ), - ).toEqual({ - operation: "/api/v1/unknown/thing", - parameters: { keep: true }, - }); - }); -}); - describe("stepRequiresUpload", () => { const step = (params: Record): WorkingToolStep => ({ toolId: "compress" as ToolId, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index 2685881bf4..d567a1dad1 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -197,31 +197,6 @@ export function serializeToolStep( return { operation, parameters }; } -/** - * Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup - * wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than - * the backend contract - into the backend step contract, mapping params through the tool's - * `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults). - * The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config - * endpoint is a function. Endpoints that map to no known tool pass through unchanged. - */ -export function serializeStepFromEndpoint( - operation: string, - params: ErasedToolParams, - registry: Partial, -): ToolApiStep { - const match = findToolByEndpoint({ operation, parameters: params }, registry); - const config = match?.[1].operationConfig; - if (!config) return { operation, parameters: params }; - const merged = { ...(config.defaultParameters ?? {}), ...params }; - return { - operation: resolveEndpoint(config, merged) ?? operation, - parameters: config.toApiParams - ? (config.toApiParams(merged) as Record) - : {}, - }; -} - /** * Find the registry tool for a stored step's endpoint: exact match for static endpoints, else * membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts new file mode 100644 index 0000000000..18bb5e282e --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vitest"; +import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor"; + +interface Params { + a: number; +} + +// A minimal config that type-checks against the flatten endpoint's model. +const CONFIG = { + endpoint: "/api/v1/misc/flatten" as const, + defaultParameters: { a: 1 } satisfies Params, + toApiParams: (p: Params) => ({ renderDpi: p.a }), + fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }), +}; + +describe("describeToolOperation", () => { + test("wraps the config's mappers and endpoint into a descriptor", () => { + const d = describeToolOperation("/api/v1/misc/flatten", CONFIG); + expect(d.endpoint).toBe("/api/v1/misc/flatten"); + expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 }); + }); + + test("fromApi merges the mapped values over the defaults", () => { + const d = describeToolOperation("/api/v1/misc/flatten", CONFIG); + expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 }); + }); + + test("throws when the config lacks a mapper", () => { + expect(() => + describeToolOperation("/api/v1/misc/flatten", { + endpoint: "/api/v1/misc/flatten" as const, + defaultParameters: { a: 1 }, + toApiParams: (p: Params) => ({ renderDpi: p.a }), + }), + ).toThrow(/mappers/); + }); +}); diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts new file mode 100644 index 0000000000..3c8f78d1b8 --- /dev/null +++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationDescriptor.ts @@ -0,0 +1,59 @@ +/** + * Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe + * frontend<->backend parameter conversion. + */ + +import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; + +export interface ToolOperationDescriptor { + readonly endpoint: E; + readonly defaultParameters: TParams; + toApi(params: TParams): ToolApiParams[E]; + /** Backend model -> full frontend params (defaults merged under the mapped values). */ + fromApi(api: ToolApiParams[E]): TParams; +} + +/** + * Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from + * the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for + * dynamic-endpoint tools (whose endpoint is a function typed against the union). + */ +export interface BidirectionalToolConfig { + endpoint: CE | null | ((params: TParams) => CE | null); + defaultParameters?: TParams; + toApiParams?(params: TParams): ToolApiParams[CE]; + fromApiParams?(api: ToolApiParams[CE]): Partial; +} + +/** + * Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as + * a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while + * allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing. + */ +export function describeToolOperation< + E extends CE, + CE extends ToolEndpoint, + TParams, +>( + endpoint: E, + config: BidirectionalToolConfig, +): ToolOperationDescriptor { + const { toApiParams, fromApiParams, defaultParameters } = config; + if (!toApiParams || !fromApiParams || defaultParameters === undefined) { + throw new Error( + `describeToolOperation: "${endpoint}" is missing mappers or defaults`, + ); + } + return { + endpoint, + defaultParameters, + // A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the + // runtime mapper produces this endpoint's model). + toApi: (params) => toApiParams(params) as ToolApiParams[E], + fromApi: (api) => + ({ + ...defaultParameters, + ...fromApiParams(api as ToolApiParams[CE]), + }) as TParams, + }; +} diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 78f8b07667..bf6e4b395c 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -13,6 +13,8 @@ import type { TFunction } from "i18next"; import { apiClient } from "@portal/api/http"; import { fromWirePolicy, toWirePolicy } from "@app/policies/codec"; import { runsToActivity, runsToStats } from "@app/policies/runs"; +import { policyStep, type PolicyToolStep } from "@app/policies/operations"; +import type { ToolEndpoint } from "@app/types/toolApiTypes"; import type { PolicyDecodedState, PolicyRunView, @@ -66,7 +68,7 @@ export interface PolicyConfigDef { rules: string[]; scopeLabel: string; fields: PolicyField[]; - defaultOperations: WirePipelineStep[]; + defaultOperations: PolicyToolStep[]; } export interface PolicyState { @@ -128,20 +130,11 @@ export interface CatalogueEntry { } /* ──────────────────────────────────────────────────────────────────────── */ -/* Tool → endpoint registry */ +/* Endpoint display labels */ /* ──────────────────────────────────────────────────────────────────────── */ -export const TOOL_ENDPOINTS: Record = { - redact: "/api/v1/security/auto-redact", - sanitize: "/api/v1/security/sanitize-pdf", - watermark: "/api/v1/security/add-watermark", - ocr: "/api/v1/misc/ocr-pdf", - flatten: "/api/v1/misc/flatten", - compress: "/api/v1/misc/compress-pdf", -}; - -/** Values are i18n keys — render with t(). */ -export const ENDPOINT_LABELS: Record = { +/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */ +export const ENDPOINT_LABELS: Partial> = { "/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact", "/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf", "/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark", @@ -154,7 +147,8 @@ export function humanizeEndpoint( path: string, t: (key: string) => string, ): string { - if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]); + const label = ENDPOINT_LABELS[path as ToolEndpoint]; + if (label) return t(label); const last = path.split("/").filter(Boolean).pop() ?? path; return last .replace(/-/g, " ") @@ -230,10 +224,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.ingestion.rules.3", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.ocr, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], + defaultOperations: [policyStep("ocr"), policyStep("flatten")], fields: [ { label: "portal.policies.config.ingestion.fields.minConfidence", @@ -260,32 +251,16 @@ export const POLICY_CONFIG: Record = { ], scopeLabel: "portal.policies.config.scopeAll", defaultOperations: [ - { - operation: TOOL_ENDPOINTS.redact, - parameters: { - mode: "automatic", - useRegex: true, - convertPDFToImage: true, - wordsToRedact: DEFAULT_PII_PATTERNS, - }, - }, - { - operation: TOOL_ENDPOINTS.sanitize, - parameters: { - removeJavaScript: true, - removeEmbeddedFiles: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, - }, - }, - { - operation: TOOL_ENDPOINTS.watermark, - // convertPDFToImage bakes the watermark in so it can't be stripped - parameters: { - convertPDFToImage: true, - }, - }, + // Flatten to image so redactions can't be lifted off. + policyStep("redact", { + useRegex: true, + convertPDFToImage: true, + wordsToRedact: DEFAULT_PII_PATTERNS, + }), + // JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off. + policyStep("sanitize", { removeEmbeddedFiles: false }), + // Bake in via image so it can't be stripped. + policyStep("watermark", { convertPDFToImage: true }), ], fields: [], }, @@ -297,10 +272,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.compliance.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.sanitize, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], + defaultOperations: [policyStep("sanitize"), policyStep("flatten")], fields: [ { label: "portal.policies.config.compliance.fields.frameworks", @@ -343,7 +315,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.routing.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], + defaultOperations: [policyStep("compress")], fields: [ { label: "portal.policies.config.routing.fields.destination", @@ -374,7 +346,7 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.retention.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], + defaultOperations: [policyStep("compress")], fields: [ { label: "portal.policies.config.retention.fields.keepFor", diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx new file mode 100644 index 0000000000..91765fe0e7 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; +import { + POLICY_CATEGORIES, + POLICY_CONFIG, + type CatalogueEntry, + type DecoratedPolicy, + type PolicySetupResult, + type PipelineStep, +} from "@portal/api/policies"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed +// because the import graph pulls core/i18n.ts, which registers it as a plugin. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // Second arg is a string fallback in some call sites and an interpolation object in others; + // only treat a string as the fallback. + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + i18n: { changeLanguage: vi.fn() }, + }), + initReactI18next: { type: "3rdParty", init: vi.fn() }, +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), +})); + +const CONTINUE = "portal.policies.wizard.actions.continue"; +const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges"; +const ENABLE = "portal.policies.wizard.actions.enablePolicy"; + +const security = POLICY_CATEGORIES.find((c) => c.id === "security")!; +const securityConfig = POLICY_CONFIG.security; + +function editEntry(steps: PipelineStep[]): CatalogueEntry { + const policy: DecoratedPolicy = { + category: security, + config: securityConfig, + state: { + configured: true, + status: "active", + sources: ["editor"], + scopeTypes: [], + reviewerEmail: "", + fieldValues: {}, + runOn: "upload", + outputMode: "new_version", + outputName: "", + outputNamePosition: "suffix", + maxRetries: 0, + retryDelayMinutes: 0, + backendId: "pol-1", + isDefault: true, + }, + steps, + stats: { enforced: 0, dataProcessed: "-", activeFor: "-" }, + activity: [], + }; + return { category: security, config: securityConfig, policy }; +} + +/** Advance the wizard from the workflow tab to the settings tab and submit. */ +async function submitWizard(saveLabel: string) { + fireEvent.click(await screen.findByRole("button", { name: CONTINUE })); + fireEvent.click(await screen.findByRole("button", { name: saveLabel })); +} + +describe("PolicySetupWizard", () => { + beforeEach(() => { + fetchSources.mockResolvedValue({ sources: [] }); + }); + + it("round-trips a saved step's backend params on edit", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const entry = editEntry([ + { + operation: "/api/v1/security/auto-redact", + parameters: { listOfText: "foo\nbar", useRegex: true }, + }, + ]); + + render( + , + ); + await submitWizard(SAVE_CHANGES); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + const result = onSubmit.mock.calls[0][1] as PolicySetupResult; + // Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip. + expect(result.steps).toEqual([ + expect.objectContaining({ + operation: "/api/v1/security/auto-redact", + parameters: expect.objectContaining({ listOfText: "foo\nbar" }), + }), + ]); + }); + + it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const entry: CatalogueEntry = { + category: security, + config: securityConfig, + policy: null, + }; + + render( + , + ); + await submitWizard(ENABLE); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + const result = onSubmit.mock.calls[0][1] as PolicySetupResult; + const endpoints = result.steps.map((s) => s.operation); + expect(endpoints).toEqual([ + "/api/v1/security/auto-redact", + "/api/v1/security/sanitize-pdf", + ]); + // Redact carries the preset PII patterns as the backend's listOfText. + const redact = result.steps[0].parameters as { listOfText?: string }; + expect(redact.listOfText).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 9dce344b89..43be7ca17c 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -13,23 +13,24 @@ import { } from "@app/ui"; import { SettingsRow } from "@app/ui/SettingsRow"; import { - TOOL_ENDPOINTS, humanizeEndpoint, type CatalogueEntry, type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; -import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy"; import { - deserializeToolStep, - serializeStepFromEndpoint, -} from "@app/hooks/tools/shared/toolAutomation"; + policyEndpoint, + policyStepFromWire, + policyStepToWire, + type PolicyParams, + type PolicyToolId, + type PolicyToolStep, +} from "@app/policies/operations"; import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; import { policyIcon } from "@portal/components/policies/policyIcons"; import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; -import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; import "@portal/views/Policies.css"; @@ -47,12 +48,8 @@ interface PolicySetupWizardProps { type Step = "workflow" | "settings"; -/** A configurable tool in the workflow step: whether it runs + its params. */ -interface ToolState { - operation: string; - enabled: boolean; - parameters: Record; -} +/** A policy step plus whether it runs. */ +type ToolState = PolicyToolStep & { enabled: boolean }; /** Resolve each field's effective value: saved override, else definition default. */ function resolveFieldValues( @@ -69,9 +66,8 @@ function resolveFieldValues( * round-trips); otherwise the category preset's default chain. Each preset step * starts enabled — the user toggles tools off in the workflow. */ -// Temporary: tracks which tools start disabled until the tool registry lands in -// the portal and can drive this via registry metadata or a defaultEnabled flag. -const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]); +// Temporary until the catalogue carries a defaultEnabled flag. +const DISABLED_BY_DEFAULT = new Set(["watermark"]); /** * Policy-facing framing for each capability a policy can include. Labels and @@ -81,43 +77,43 @@ const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]); * the humanised endpoint name with no description. */ const CAPABILITY_META: Record< - string, + PolicyToolId, { labelKey: string; labelEn: string; descKey: string; descEn: string } > = { - [TOOL_ENDPOINTS.redact]: { + redact: { labelKey: "portal.policies.wizard.capability.redact.label", labelEn: "Redact sensitive information", descKey: "portal.policies.wizard.capability.redact.desc", descEn: "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.", }, - [TOOL_ENDPOINTS.sanitize]: { + sanitize: { labelKey: "portal.policies.wizard.capability.sanitize.label", labelEn: "Strip active content", descKey: "portal.policies.wizard.capability.sanitize.desc", descEn: "Removes hidden JavaScript so nothing can run automatically when the document is opened.", }, - [TOOL_ENDPOINTS.watermark]: { + watermark: { labelKey: "portal.policies.wizard.capability.watermark.label", labelEn: "Apply a watermark", descKey: "portal.policies.wizard.capability.watermark.desc", descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.", }, - [TOOL_ENDPOINTS.ocr]: { + ocr: { labelKey: "portal.policies.wizard.capability.ocr.label", labelEn: "Make text searchable", descKey: "portal.policies.wizard.capability.ocr.desc", descEn: "Runs OCR so scanned pages become selectable, searchable text.", }, - [TOOL_ENDPOINTS.flatten]: { + flatten: { labelKey: "portal.policies.wizard.capability.flatten.label", labelEn: "Flatten the document", descKey: "portal.policies.wizard.capability.flatten.desc", descEn: "Merges form fields and annotations into the page so they can't be edited.", }, - [TOOL_ENDPOINTS.compress]: { + compress: { labelKey: "portal.policies.wizard.capability.compress.label", labelEn: "Reduce file size", descKey: "portal.policies.wizard.capability.compress.desc", @@ -125,29 +121,24 @@ const CAPABILITY_META: Record< }, }; -function seedTools( - entry: CatalogueEntry, - registry: Partial, -): ToolState[] { +function seedTools(entry: CatalogueEntry): ToolState[] { const savedSteps = entry.policy?.steps ?? []; - const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); - // Always use defaultOperations as the canonical list so tools added after a - // policy was first saved still appear when editing. - return entry.config.defaultOperations.map((s) => { - const saved = savedByOp.get(s.operation); + const savedByTool = new Map(); + for (const wire of savedSteps) { + const step = policyStepFromWire(wire); + if (step) savedByTool.set(step.toolId, step); + } + // defaultOperations is the canonical list (so tools added later still show on edit); a saved + // step's params win over the preset. + return entry.config.defaultOperations.map((preset) => { + const saved = savedByTool.get(preset.toolId); return { - operation: s.operation, + ...(saved ?? preset), enabled: saved ? true : savedSteps.length > 0 ? false - : !DISABLED_BY_DEFAULT.has(s.operation), - // Saved steps are in the backend contract shape; map them back to the UI - // shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`). - // Presets are already authored in the UI shape, so use them as-is. - parameters: saved - ? deserializeToolStep(saved, registry).params - : s.parameters, + : !DISABLED_BY_DEFAULT.has(preset.toolId), }; }); } @@ -185,26 +176,12 @@ function PolicySetupWizardBody({ onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise; }) { const { t } = useTranslation(); - const { allTools: toolRegistry } = useToolRegistry(); - - // Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs. - // Build a reverse map so we can look up icons and display names by endpoint. - const registryByEndpoint = useMemo(() => { - const map = new Map(); - for (const entry of Object.values(toolRegistry)) { - const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint; - if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry); - } - return map; - }, [toolRegistry]); const { category, config, policy } = entry; const isEdit = policy != null; const [step, setStep] = useState("workflow"); - const [tools, setTools] = useState(() => - seedTools(entry, toolRegistry), - ); + const [tools, setTools] = useState(() => seedTools(entry)); const [fieldValues, setFieldValues] = useState(() => resolveFieldValues(entry), ); @@ -245,9 +222,20 @@ function PolicySetupWizardBody({ const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]); - function patchTool(operation: string, patch: Partial) { + function setToolEnabled(toolId: PolicyToolId, enabled: boolean) { setTools((prev) => - prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)), + prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)), + ); + } + + function setToolParams( + toolId: Id, + params: PolicyParams, + ) { + setTools((prev) => + prev.map((tl) => + tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl, + ), ); } @@ -266,11 +254,8 @@ function PolicySetupWizardBody({ } setError(null); setSubmitting(true); - // Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the - // backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the - // UI shape verbatim would drop those fields and the step would run with none. const steps: PipelineStep[] = enabledTools.map((tl) => - serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry), + policyStepToWire(tl), ); try { await onSubmit(entry, { @@ -375,20 +360,16 @@ function PolicySetupWizardBody({
{tools.map((tl) => { - const meta = CAPABILITY_META[tl.operation]; + const meta = CAPABILITY_META[tl.toolId]; const label = meta ? t(meta.labelKey, meta.labelEn) - : (registryByEndpoint.get(tl.operation)?.name ?? - humanizeEndpoint(tl.operation, t)); + : humanizeEndpoint(policyEndpoint(tl.toolId), t); const description = meta ? t(meta.descKey, meta.descEn) : undefined; - const hasConfig = - tl.operation === TOOL_ENDPOINTS.redact || - tl.operation === TOOL_ENDPOINTS.watermark; return (
@@ -400,27 +381,27 @@ function PolicySetupWizardBody({ size="sm" checked={tl.enabled} onChange={(checked) => - patchTool(tl.operation, { enabled: checked }) + setToolEnabled(tl.toolId, checked) } label="" /> } /> - {tl.enabled && hasConfig && ( + {tl.enabled && (
- {tl.operation === TOOL_ENDPOINTS.redact && ( + {tl.toolId === "redact" && ( - patchTool(tl.operation, { parameters }) + parameters={tl.params} + onChange={(params) => + setToolParams("redact", params) } /> )} - {tl.operation === TOOL_ENDPOINTS.watermark && ( + {tl.toolId === "watermark" && ( - patchTool(tl.operation, { parameters }) + parameters={tl.params} + onChange={(params) => + setToolParams("watermark", params) } /> )} diff --git a/frontend/editor/src/portal/mocks/policies.ts b/frontend/editor/src/portal/mocks/policies.ts index 27d5dfeadb..64a9b2ed19 100644 --- a/frontend/editor/src/portal/mocks/policies.ts +++ b/frontend/editor/src/portal/mocks/policies.ts @@ -4,13 +4,33 @@ * only builds seed data for the MSW handlers and tests. */ -import type { PolicyRunView, WirePolicy } from "@app/policies/types"; -import { POLICY_CONFIG } from "@portal/api/policies"; +import type { + PolicyRunView, + WirePipelineStep, + WirePolicy, +} from "@app/policies/types"; /* ──────────────────────────────────────────────────────────────────────── */ /* Seed data — real backend wire format */ /* ──────────────────────────────────────────────────────────────────────── */ +// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of +// @portal/api/policies and its heavy tool-operation import graph. +const SECURITY_STEPS: WirePipelineStep[] = [ + { + operation: "/api/v1/security/auto-redact", + parameters: { + listOfText: "", + useRegex: true, + convertPDFToImage: true, + }, + }, + { + operation: "/api/v1/security/sanitize-pdf", + parameters: { removeJavaScript: true }, + }, +]; + export function seedPolicies(): WirePolicy[] { return [ { @@ -19,7 +39,7 @@ export function seedPolicies(): WirePolicy[] { owner: "security@acme.com", enabled: true, trigger: null, - steps: POLICY_CONFIG.security.defaultOperations, + steps: SECURITY_STEPS, output: { type: "inline", options: { diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx index 59fe76c021..e464109567 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx @@ -1,6 +1,7 @@ import { MultiSelect } from "@app/ui/MultiSelect"; import { useTranslation } from "react-i18next"; import { PII_PRESETS } from "@app/data/policyDefinitions"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; /** 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)); @@ -8,8 +9,8 @@ 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; + parameters: RedactParameters; + onChange: (parameters: RedactParameters) => void; disabled?: boolean; } @@ -26,9 +27,7 @@ export function PolicyPiiField({ disabled, }: PolicyPiiFieldProps) { const { t } = useTranslation(); - const words = Array.isArray(parameters.wordsToRedact) - ? (parameters.wordsToRedact as string[]) - : []; + const words = parameters.wordsToRedact; const selected = words .map((w) => VALUE_BY_PATTERN.get(w)) .filter((v): v is string => Boolean(v)); diff --git a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx index 0823c59e0b..9d5e01e49f 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.tsx @@ -1,9 +1,10 @@ import { useEffect } from "react"; import { PolicyPiiField } from "@app/components/policies/PolicyPiiField"; +import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; interface PolicyRedactConfigProps { - parameters: Record; - onChange: (parameters: Record) => void; + parameters: RedactParameters; + onChange: (parameters: RedactParameters) => void; disabled?: boolean; } diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx index 8697c9d030..ad62c4f890 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx @@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters"; interface PolicyWatermarkConfigProps { - parameters: Record; - onChange: (parameters: Record) => void; + parameters: AddWatermarkParameters; + onChange: (parameters: AddWatermarkParameters) => void; disabled?: boolean; } @@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({ disabled, }: PolicyWatermarkConfigProps) { useEffect(() => { - const patch: Record = {}; + const patch: Partial = {}; if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true; // Policies only support text watermarks. if (parameters.watermarkType !== "text") patch.watermarkType = "text"; @@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({ return ( onChange({ ...parameters, [key]: value }) } diff --git a/frontend/editor/src/proprietary/policies/operations.test.ts b/frontend/editor/src/proprietary/policies/operations.test.ts new file mode 100644 index 0000000000..491a15aa59 --- /dev/null +++ b/frontend/editor/src/proprietary/policies/operations.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; +import { + POLICY_OPERATIONS, + policyEndpoint, + policyStep, + policyStepFromWire, + policyStepToWire, + policyToolIdForEndpoint, + type PolicyToolId, +} from "@app/policies/operations"; + +const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[]; + +describe("POLICY_OPERATIONS", () => { + test("every category operation is a typed descriptor with a known endpoint", () => { + // The catalogue uses these six across all categories; each must be wired. + expect(ALL_TOOL_IDS.sort()).toEqual([ + "compress", + "flatten", + "ocr", + "redact", + "sanitize", + "watermark", + ]); + for (const id of ALL_TOOL_IDS) { + expect(POLICY_OPERATIONS[id].endpoint).toBe(policyEndpoint(id)); + expect(typeof POLICY_OPERATIONS[id].toApi).toBe("function"); + expect(typeof POLICY_OPERATIONS[id].fromApi).toBe("function"); + } + }); + + test("policyEndpoint returns the pinned endpoint literal", () => { + expect(policyEndpoint("redact")).toBe("/api/v1/security/auto-redact"); + expect(policyEndpoint("sanitize")).toBe("/api/v1/security/sanitize-pdf"); + expect(policyEndpoint("watermark")).toBe("/api/v1/security/add-watermark"); + expect(policyEndpoint("ocr")).toBe("/api/v1/misc/ocr-pdf"); + expect(policyEndpoint("flatten")).toBe("/api/v1/misc/flatten"); + expect(policyEndpoint("compress")).toBe("/api/v1/misc/compress-pdf"); + }); + + test("policyToolIdForEndpoint maps endpoints back, and rejects non-policy ones", () => { + for (const id of ALL_TOOL_IDS) { + expect(policyToolIdForEndpoint(policyEndpoint(id))).toBe(id); + } + expect(policyToolIdForEndpoint("/api/v1/misc/repair")).toBeNull(); + expect(policyToolIdForEndpoint("not-an-endpoint")).toBeNull(); + }); +}); + +describe("policyStep", () => { + test("merges partial params over the tool's defaults", () => { + const step = policyStep("redact", { + useRegex: true, + wordsToRedact: ["ssn", "card"], + }); + expect(step.toolId).toBe("redact"); + // Overrides applied... + expect(step.params.useRegex).toBe(true); + expect(step.params.wordsToRedact).toEqual(["ssn", "card"]); + // ...and untouched fields fall back to the tool's defaults. + expect(step.params.mode).toBe("automatic"); + expect(step.params.redactColor).toBe("#000000"); + }); +}); + +describe("wire conversion", () => { + test("redact maps frontend params to the backend request model (wordsToRedact -> listOfText)", () => { + const wire = policyStepToWire( + policyStep("redact", { + useRegex: true, + convertPDFToImage: true, + wordsToRedact: ["ssn", "card"], + }), + ); + expect(wire.operation).toBe("/api/v1/security/auto-redact"); + // The backend field the endpoint actually reads, and no frontend-only `mode`/`wordsToRedact`. + expect(wire.parameters).toMatchObject({ + listOfText: "ssn\ncard", + useRegex: true, + convertPDFToImage: true, + }); + expect(wire.parameters).not.toHaveProperty("wordsToRedact"); + expect(wire.parameters).not.toHaveProperty("mode"); + }); + + test("every policy operation round-trips through wire and back", () => { + for (const id of ALL_TOOL_IDS) { + const step = policyStep(id); + const back = policyStepFromWire(policyStepToWire(step)); + expect(back?.toolId).toBe(id); + } + }); + + test("redact round-trip preserves the configured patterns", () => { + const step = policyStep("redact", { + useRegex: true, + wordsToRedact: ["ssn", "card"], + }); + const back = policyStepFromWire(policyStepToWire(step)); + expect(back?.toolId).toBe("redact"); + if (back?.toolId === "redact") { + expect(back.params.wordsToRedact).toEqual(["ssn", "card"]); + expect(back.params.useRegex).toBe(true); + } + }); + + test("a non-policy endpoint decodes to null", () => { + expect( + policyStepFromWire({ operation: "/api/v1/misc/repair", parameters: {} }), + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/proprietary/policies/operations.ts b/frontend/editor/src/proprietary/policies/operations.ts new file mode 100644 index 0000000000..66e92c448a --- /dev/null +++ b/frontend/editor/src/proprietary/policies/operations.ts @@ -0,0 +1,131 @@ +/** + * The tool operations the Policies feature can run, each a typed {@link ToolOperationDescriptor}. + * Source of truth for the catalogue, wizard, and wire conversion. Add a tool here to use it in a + * policy - the catalogue can't reference an untyped operation. + */ + +import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor"; +import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation"; +import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation"; +import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation"; +import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation"; +import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation"; +import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; +import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor"; +import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes"; +import type { WirePipelineStep } from "@app/policies/types"; + +export const POLICY_OPERATIONS = { + redact: describeToolOperation( + "/api/v1/security/auto-redact", + redactOperationConfig, + ), + sanitize: describeToolOperation( + "/api/v1/security/sanitize-pdf", + sanitizeOperationConfig, + ), + watermark: describeToolOperation( + "/api/v1/security/add-watermark", + addWatermarkOperationConfig, + ), + ocr: describeToolOperation("/api/v1/misc/ocr-pdf", ocrOperationConfig), + flatten: describeToolOperation( + "/api/v1/misc/flatten", + flattenOperationConfig, + ), + compress: describeToolOperation( + "/api/v1/misc/compress-pdf", + compressOperationConfig, + ), +} as const; + +export type PolicyToolId = keyof typeof POLICY_OPERATIONS; + +export type PolicyParams = + (typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor< + ToolEndpoint, + infer P + > + ? P + : never; + +/** Discriminated on `toolId` so `params` matches the tool. */ +export type PolicyToolStep = { + [Id in PolicyToolId]: { toolId: Id; params: PolicyParams }; +}[PolicyToolId]; + +export type PolicyToolStepOf = Extract< + PolicyToolStep, + { toolId: Id } +>; + +const POLICY_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[]; + +const TOOL_ID_BY_ENDPOINT = new Map( + POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]), +); + +export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint { + return POLICY_OPERATIONS[toolId].endpoint; +} + +/** Tool id for an endpoint path, or null if it isn't a policy tool. */ +export function policyToolIdForEndpoint(endpoint: string): PolicyToolId | null { + return TOOL_ID_BY_ENDPOINT.get(endpoint) ?? null; +} + +/** A step for `toolId`, partial params merged over the tool's defaults. */ +export function policyStep( + toolId: Id, + params: Partial> = {}, +): PolicyToolStepOf { + const defaults = POLICY_OPERATIONS[toolId].defaultParameters as object; + return { + toolId, + params: { ...defaults, ...(params as object) }, + } as PolicyToolStepOf; +} + +export function policyStepToWire(step: PolicyToolStep): WirePipelineStep { + return serializeStep(step); +} + +// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through +// the union, so `op` is widened here (a contained cast at the wire boundary). +function serializeStep(step: { + toolId: Id; + params: PolicyParams; +}): WirePipelineStep { + const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor< + ToolEndpoint, + PolicyParams + >; + return { + operation: op.endpoint, + parameters: op.toApi(step.params) as Record, + }; +} + +/** Wire step -> typed policy step, or null if the endpoint isn't a policy tool. */ +export function policyStepFromWire( + wire: WirePipelineStep, +): PolicyToolStep | null { + const toolId = policyToolIdForEndpoint(wire.operation); + if (!toolId) return null; + return deserializeStep(toolId, wire.parameters); +} + +function deserializeStep( + toolId: Id, + parameters: Record, +): PolicyToolStepOf { + const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor< + ToolEndpoint, + PolicyParams + >; + // Wire params are untyped JSON; this is the one point they enter the typed model. + const params = op.fromApi( + parameters as unknown as ToolApiParams[ToolEndpoint], + ); + return { toolId, params } as unknown as PolicyToolStepOf; +} From 0570c4c4d9e7bf4786a02ab770ef75f116ac3719 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:30:33 +0100 Subject: [PATCH 020/109] Create-PDF engine: render from a structured document (#7018) --- app/allowed-licenses.json | 12 ++ app/proprietary/build.gradle | 14 ++ .../api/CreatePdfAgentController.java | 55 ++++-- .../model/api/ai/create/AiDocument.java | 35 ++++ .../service/AiDocumentHtmlRenderer.java | 135 +++++++++++++ .../templates/ai/create}/document.html.jinja2 | 40 ++-- .../policy/engine/PolicyExecutorTest.java | 4 +- .../service/AiDocumentHtmlRendererTest.java | 139 ++++++++++++++ build.gradle | 9 + engine/pyproject.toml | 1 - .../src/stirling/agents/pdf_create/agent.py | 41 ++-- engine/src/stirling/contracts/pdf_create.py | 14 +- .../src/stirling/models/agent_tool_models.py | 2 +- engine/tests/agents/test_pdf_create.py | 177 +++--------------- engine/uv.lock | 2 - 15 files changed, 453 insertions(+), 227 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java rename {engine/src/stirling/agents/pdf_create/templates => app/proprietary/src/main/resources/templates/ai/create}/document.html.jinja2 (87%) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9f1ff96359..88ed8ba4d5 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -208,6 +208,18 @@ "moduleName": ".*", "moduleLicense": "The W3C License" }, + { + "moduleName": "com.google.re2j:re2j", + "moduleLicense": "Go License" + }, + { + "moduleName": "com.hubspot:algebra", + "moduleLicense": null + }, + { + "moduleName": "com.hubspot.immutables:immutables-exceptions", + "moduleLicense": null + }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index fb21ed0d0c..1e51b820ae 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -66,6 +66,20 @@ dependencies { implementation "com.google.code.gson:gson:${gsonVersion}" + // jinjava/jjwt transitively request older Jackson 2 versions; declare the current + // version directly so it is selected consistently (root build.gradle pins are the fallback). + runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + + implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") { + // Compile-time-only annotation artifacts (class-retention annotations, not needed at + // runtime) whose declared licences (LGPL / none) fail the licence compatibility check. + exclude group: 'com.google.code.findbugs', module: 'annotations' + exclude group: 'org.derive4j', module: 'derive4j-annotation' + exclude group: 'com.hubspot.immutables', module: 'hubspot-style' + exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings' + } + api 'io.micrometer:micrometer-registry-prometheus' api "io.jsonwebtoken:jjwt-api:${jwtVersion}" diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java index 15b7982dec..5e2298937a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/CreatePdfAgentController.java @@ -8,12 +8,14 @@ import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.Hidden; @@ -24,18 +26,24 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.model.api.ai.create.AiDocument; +import stirling.software.proprietary.service.AiDocumentHtmlRenderer; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; /** - * Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint. + * Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint. * *

Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine - * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja - * template so sanitization is intentionally skipped. + * emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as + * structured fields; the HTML is built here from a fixed template. */ @Slf4j @Hidden @@ -48,6 +56,9 @@ public class CreatePdfAgentController { private final TempFileManager tempFileManager; private final CustomPDFDocumentFactory pdfDocumentFactory; private final RuntimePathConfig runtimePathConfig; + private final ApplicationProperties applicationProperties; + private final ObjectMapper objectMapper; + private final AiDocumentHtmlRenderer htmlRenderer; /** * Returns true only when WeasyPrint is definitively unavailable — either the binary could not @@ -74,32 +85,42 @@ public class CreatePdfAgentController { value = "/create-pdf-from-html-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( - summary = "Convert AI-generated HTML to a PDF", + summary = "Convert an AI-generated document to a PDF", description = - "Accepts an HTML document as a plain-text parameter and returns a PDF." - + " This endpoint is dispatched by the AI workflow orchestrator as a" - + " plan step; it is not intended for direct client use.") - public ResponseEntity createPdfFromHtml( - @RequestParam("htmlContent") String htmlContent, - @RequestParam("filename") String filename) + "Accepts a structured document as a JSON parameter and returns a PDF. This" + + " endpoint is dispatched by the AI workflow orchestrator as a plan" + + " step; it is not intended for direct client use.") + public ResponseEntity createPdf( + @RequestParam("document") String document, @RequestParam("filename") String filename) throws Exception { + if (!applicationProperties.getAiEngine().isEnabled()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + + AiDocument model; + try { + model = objectMapper.readValue(document, AiDocument.class); + } catch (JacksonException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST); + } + + String html = htmlRenderer.render(model); + log.info( - "[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}", - htmlContent.length()); + "[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}", + html.length()); try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html"); TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) { - Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8); + Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8); List command = new ArrayList<>(); command.add(runtimePathConfig.getWeasyPrintPath()); command.add("-e"); command.add("utf-8"); command.add("-v"); - // SSRF: the HTML is self-contained and the engine validates style colours, so no - // external url() reaches WeasyPrint. For full isolation, run it network-isolated. command.add(htmlFile.getAbsolutePath()); command.add(pdfFile.getAbsolutePath()); @@ -126,8 +147,8 @@ public class CreatePdfAgentController { // avoids materialising the whole document as a byte[] twice (read-all + re-serialise), // which matters for large generated documents. TempFile tempOut = tempFileManager.createManagedTempFile(".pdf"); - try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) { - document.save(tempOut.getPath().toFile()); + try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) { + pdDocument.save(tempOut.getPath().toFile()); } catch (Exception e) { tempOut.close(); throw e; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java new file mode 100644 index 0000000000..6fd95728c2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/create/AiDocument.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.model.api.ai.create; + +import java.util.List; + +import lombok.Data; + +@Data +public class AiDocument { + + private String title; + private String subtitle; + private String referenceNumber; + private Style style; + private List

sections; + + @Data + public static class Style { + private String primaryColor; + private String backgroundColor; + private String bodyTextColor; + } + + @Data + public static class Section { + private String type; + private String heading; + private String body; + private List> pairs; + private List columns; + private List> rows; + private List totalRow; + private List items; + private List signatories; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java new file mode 100644 index 0000000000..26eff3113c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiDocumentHtmlRenderer.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.service; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */ +@Component +public class AiDocumentHtmlRenderer { + + private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2"; + + private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$"); + + private final Jinjava jinjava; + private final String template; + + public AiDocumentHtmlRenderer() { + JinjavaConfig config = + JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build(); + this.jinjava = new Jinjava(config); + this.template = loadTemplate(); + } + + public String render(AiDocument doc) { + return jinjava.render(template, buildContext(doc)); + } + + private static Map buildContext(AiDocument doc) { + Map context = new LinkedHashMap<>(); + context.put("title", doc.getTitle()); + context.put("subtitle", doc.getSubtitle()); + context.put("reference_number", doc.getReferenceNumber()); + + AiDocument.Style style = doc.getStyle(); + if (style != null) { + context.put("style_primary", safeColor(style.getPrimaryColor())); + context.put("style_background", safeColor(style.getBackgroundColor())); + context.put("style_body", safeColor(style.getBodyTextColor())); + } + + List> sections = new ArrayList<>(); + if (doc.getSections() != null) { + for (AiDocument.Section section : doc.getSections()) { + if (section != null && section.getType() != null) { + sections.add(buildSection(section)); + } + } + } + context.put("sections", sections); + return context; + } + + private static Map buildSection(AiDocument.Section section) { + Map node = new LinkedHashMap<>(); + node.put("type", section.getType()); + node.put("heading", section.getHeading()); + switch (section.getType()) { + case "text" -> node.put("paragraphs", paragraphs(section.getBody())); + case "key_value" -> node.put("pairs", pairs(section.getPairs())); + case "line_items" -> { + node.put("columns", orEmpty(section.getColumns())); + node.put("rows", orEmptyRows(section.getRows())); + node.put("total_row", emptyToNull(section.getTotalRow())); + } + case "bullet_list" -> node.put("items", orEmpty(section.getItems())); + case "signature" -> node.put("signatories", orEmpty(section.getSignatories())); + default -> {} + } + return node; + } + + private static List paragraphs(String body) { + String text = body == null ? "" : body; + List out = new ArrayList<>(); + for (String paragraph : text.split("\n\n")) { + out.add(paragraph.replace("\n", " ")); + } + return out; + } + + private static List> pairs(List> pairs) { + List> out = new ArrayList<>(); + if (pairs != null) { + for (List pair : pairs) { + Map node = new LinkedHashMap<>(); + node.put("label", pair.isEmpty() ? "" : pair.get(0)); + node.put("value", pair.size() < 2 ? "" : pair.get(1)); + out.add(node); + } + } + return out; + } + + private static List orEmpty(List values) { + return values == null ? List.of() : values; + } + + private static List> orEmptyRows(List> rows) { + return rows == null ? List.of() : rows; + } + + private static List emptyToNull(List values) { + return values == null || values.isEmpty() ? null : values; + } + + private static String safeColor(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null; + } + + private static String loadTemplate() { + try { + return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 similarity index 87% rename from engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 rename to app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 index b969458f5e..0a4a2cfc20 100644 --- a/engine/src/stirling/agents/pdf_create/templates/document.html.jinja2 +++ b/app/proprietary/src/main/resources/templates/ai/create/document.html.jinja2 @@ -1,3 +1,4 @@ +{%- autoescape true -%} @@ -175,18 +176,18 @@ color: var(--color-label); } -{%- if doc.style %} +{%- if style_primary or style_background or style_body %} @@ -195,16 +196,16 @@
-
{{ doc.title }}
- {%- if doc.subtitle %} -
{{ doc.subtitle }}
+
{{ title }}
+ {%- if subtitle %} +
{{ subtitle }}
{%- endif %} - {%- if doc.reference_number %} -
{{ doc.reference_number }}
+ {%- if reference_number %} +
{{ reference_number }}
{%- endif %}
-{%- for section in doc.sections %} +{%- for section in sections %} {%- if section.type == "text" %}
@@ -212,8 +213,8 @@

{{ section.heading }}

{%- endif %}
- {%- for para in section.body.split('\n\n') %} -

{{ para | replace('\n', ' ') }}

+ {%- for para in section.paragraphs %} +

{{ para }}

{%- endfor %}
@@ -225,10 +226,10 @@ {%- endif %} - {%- for label, value in section.pairs %} + {%- for pair in section.pairs %} - - + + {%- endfor %} @@ -299,3 +300,4 @@ +{%- endautoescape %} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index 9b10d9090e..4d9ea1deb7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -166,8 +166,8 @@ class PolicyExecutorTest { new PipelineStep( createPdf, Map.of( - "htmlContent", - "

hi

", + "document", + "{\"title\":\"PO\",\"sections\":[]}", "filename", "purchase-order.pdf"))), PolicyInputs.of(List.of()), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java new file mode 100644 index 0000000000..7b491c3539 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiDocumentHtmlRendererTest.java @@ -0,0 +1,139 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.api.ai.create.AiDocument; + +class AiDocumentHtmlRendererTest { + + private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer(); + + private static AiDocument.Section section(String type) { + AiDocument.Section s = new AiDocument.Section(); + s.setType(type); + return s; + } + + private static AiDocument document(String title, List sections) { + AiDocument doc = new AiDocument(); + doc.setTitle(title); + doc.setSections(sections); + return doc; + } + + @Test + void rendersAllSectionTypes() { + AiDocument.Section text = section("text"); + text.setBody("Some prose text."); + AiDocument.Section kv = section("key_value"); + kv.setPairs(List.of(List.of("Key", "Value"))); + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("A", "B")); + items.setRows(List.of(List.of("1", "2"))); + AiDocument.Section bullets = section("bullet_list"); + bullets.setItems(List.of("item one")); + AiDocument.Section sign = section("signature"); + sign.setSignatories(List.of("Alice")); + + String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign))); + + assertTrue(html.contains("")); + assertTrue(html.contains("Some prose text.")); + assertTrue(html.contains("Key") && html.contains("Value")); + assertTrue(html.contains("")); + } + + @Test + void totalRowAbsentWhenNotProvided() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item")); + items.setRows(List.of(List.of("Widget"))); + + assertFalse( + renderer.render(document("Table", List.of(items))) + .contains("")); + } + + @Test + void rendersSubtitleAndReference() { + AiDocument doc = document("My Doc", List.of()); + doc.setSubtitle("Subtitle Here"); + doc.setReferenceNumber("REF-42"); + + String html = renderer.render(doc); + + assertTrue(html.contains("Subtitle Here")); + assertTrue(html.contains("REF-42")); + } + + @Test + void appliesHexColourOverride() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("#ff00ff"); + style.setBackgroundColor("#111111"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertTrue(html.contains("--color-primary: #ff00ff")); + assertTrue(html.contains("--color-bg: #111111")); + } + + @Test + void ignoresColourWithDisallowedCharacters() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("rgb(255, 0, 0)"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("rgb(")); + assertTrue(html.contains("")); + } + + @Test + void ignoresNonHexColour() { + AiDocument doc = document("Styled", List.of()); + AiDocument.Style style = new AiDocument.Style(); + style.setPrimaryColor("magenta"); + style.setBackgroundColor("#fff"); + doc.setStyle(style); + + String html = renderer.render(doc); + + assertFalse(html.contains("--color-primary: magenta")); + assertFalse(html.contains("--color-bg: #fff;")); + } +} diff --git a/build.gradle b/build.gradle index 073bccc654..e2f47e26fb 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ ext { okhttpBomVersion = "5.3.2" gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" + jinjavaVersion = "2.8.3" + jackson2Version = "2.21.2" bucket4jVersion = "8.19.0" archunitVersion = "1.4.2" batikVersion = "1.19" @@ -222,6 +224,13 @@ subprojects { resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}" // CVE-2024-47554: commons-io DoS prevention resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" + // Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions); + // pin the family to a current release and keep modules aligned. + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}" + resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}" // Keep BouncyCastle modules aligned to avoid runtime linkage errors resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}" resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}" diff --git a/engine/pyproject.toml b/engine/pyproject.toml index fa972c8495..7bd2fa7bea 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -5,7 +5,6 @@ description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ "fastapi>=0.116.0", - "jinja2>=3.1.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", "pydantic>=2.0.0", diff --git a/engine/src/stirling/agents/pdf_create/agent.py b/engine/src/stirling/agents/pdf_create/agent.py index bd159060df..671d4867ca 100644 --- a/engine/src/stirling/agents/pdf_create/agent.py +++ b/engine/src/stirling/agents/pdf_create/agent.py @@ -10,7 +10,7 @@ Flow: 4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather. Each returns a WrittenSections with fully populated DocumentSection objects. 5. The assembler collects sections in plan order → GeneratedDocument. - 6. Jinja renders the document to HTML. The LLM never writes HTML. + 6. The assembled document is emitted as structured fields. The LLM never writes HTML. The planner is split into two calls (meta then sections) so each LLM output schema stays small enough for grammar compilation on all model tiers including Haiku. @@ -22,9 +22,7 @@ import asyncio import logging import re from dataclasses import dataclass -from pathlib import Path -from jinja2 import Environment, FileSystemLoader from pydantic_ai import Agent from pydantic_ai.output import NativeOutput @@ -51,8 +49,6 @@ from stirling.services import AppRuntime logger = logging.getLogger(__name__) -_TEMPLATES_DIR = Path(__file__).parent / "templates" - # ── Token budget ────────────────────────────────────────────────────────────────────────────────── # Conservative per-section token estimates mapped from planner-assigned depth. @@ -166,10 +162,13 @@ Analyse the user's request and produce a DocumentMeta with: document, if the user provides one. Leave empty if the user provides no such context. - style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a - colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours - (e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated. -- style_background_color: page background colour. Set only if explicitly requested. -- style_body_text_color: body text colour. Set only if explicitly requested. + colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex + code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" → + "#000080"). No other format is accepted. Leave null if no colour is stated. +- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly + requested. +- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly + requested. - cannot_do_reason: set this ONLY when the request is not asking to create a document at all (e.g. a question, a greeting, an edit request to an existing document). Never set it @@ -299,15 +298,6 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str: # ── Helpers ─────────────────────────────────────────────────────────────────────────────────────── -def _build_jinja_env() -> Environment: - return Environment( - loader=FileSystemLoader(str(_TEMPLATES_DIR)), - autoescape=True, - trim_blocks=True, - lstrip_blocks=True, - ) - - def _safe_filename(title: str) -> str: slug = re.sub(r"[^\w\s-]", "", title.lower()) slug = re.sub(r"[\s_-]+", "-", slug).strip("-") @@ -320,7 +310,6 @@ def _safe_filename(title: str) -> str: class PdfCreateAgent: def __init__(self, runtime: AppRuntime) -> None: self.runtime = runtime - self._jinja_env = _build_jinja_env() self._meta_planner: Agent[None, DocumentMeta] = Agent( model=runtime.smart_model, @@ -401,14 +390,12 @@ class PdfCreateAgent: sections=all_sections, ) - # ── Phase 6: render ──────────────────────────────────────────────────── - logger.info("[pdf-create] phase 6/6: rendering HTML") - html = self._render(doc) + # ── Phase 6: emit ────────────────────────────────────────────────────── filename = _safe_filename(plan.title) logger.info( - "[pdf-create] done — filename=%r html_bytes=%d", + "[pdf-create] done — filename=%r sections=%d", filename, - len(html), + len(all_sections), ) return EditPlanResponse( @@ -417,7 +404,7 @@ class PdfCreateAgent: ToolOperationStep( tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT, parameters=CreatePdfFromHtmlAgentParams( - html_content=html, + document=doc.model_dump_json(), filename=filename, ), ) @@ -437,7 +424,3 @@ class PdfCreateAgent: len(result.output.sections), ) return result.output - - def _render(self, doc: GeneratedDocument) -> str: - template = self._jinja_env.get_template("document.html.jinja2") - return template.render(doc=doc) diff --git a/engine/src/stirling/contracts/pdf_create.py b/engine/src/stirling/contracts/pdf_create.py index 761a192dd1..00d5eb4091 100644 --- a/engine/src/stirling/contracts/pdf_create.py +++ b/engine/src/stirling/contracts/pdf_create.py @@ -1,14 +1,14 @@ """Contracts for the PDF Create Agent. The agent accepts a natural-language prompt and returns a single -CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML. +CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document. Pipeline: 1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text. 2. Python chunks the plan by token budget. 3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk. 4. Assembler collects sections in plan order → GeneratedDocument. - 5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML. + 5. The document is emitted as structured fields. The LLM never writes HTML. """ from __future__ import annotations @@ -81,14 +81,12 @@ type DocumentSection = Annotated[ ] -# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the -# +
diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.css b/frontend/editor/src/core/components/onboarding/OnboardingTour.css index a1cbd4f3d8..a689b9f3f4 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.css +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.css @@ -13,7 +13,7 @@ box-shadow: 0 0 0 2px var(--mantine-primary-color-filled), 0 0 15px var(--mantine-primary-color-filled), - inset 0 0 15px rgba(59, 130, 246, 0.1); + inset 0 0 15px color-mix(in srgb, var(--c-primary) 10%, transparent); border-radius: 8px; } @@ -23,13 +23,13 @@ box-shadow: 0 0 0 3px var(--mantine-primary-color-filled), 0 0 20px var(--mantine-primary-color-filled), - inset 0 0 20px rgba(59, 130, 246, 0.1); + inset 0 0 20px color-mix(in srgb, var(--c-primary) 10%, transparent); } 50% { box-shadow: 0 0 0 3px var(--mantine-primary-color-filled), 0 0 30px var(--mantine-primary-color-filled), - inset 0 0 30px rgba(59, 130, 246, 0.2); + inset 0 0 30px color-mix(in srgb, var(--c-primary) 20%, transparent); } } diff --git a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css index e278703955..7ca0b4fd93 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css +++ b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css @@ -40,7 +40,7 @@ position: absolute; border-radius: 50%; pointer-events: none; - box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.12); animation-name: circleSway; animation-timing-function: ease-in-out; animation-iteration-count: infinite; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index efb95b95bb..b76eff2c30 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -129,9 +129,9 @@ export const DesktopInstallTitle: React.FC = ({ leftSection={iconKey ? : undefined} style={{ backgroundColor: isSelected - ? "var(--bg-muted, #f1f5f9)" + ? "var(--c-surface-sunken, #f1f5f9)" : "transparent", - color: "var(--onboarding-title, #0f172a)", + color: "var(--c-text, #0f172a)", fontWeight: isSelected ? 600 : 500, }} > diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 89de08e6e7..d684592a4a 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -125,7 +125,7 @@ function FirstLoginForm({ icon="info-rounded" width={20} height={20} - style={{ color: "#3B82F6", flexShrink: 0 }} + style={{ color: "var(--c-primary)", flexShrink: 0 }} /> {t( diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 02b3e8502c..6101605772 100644 --- a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -26,7 +26,7 @@ export default function SecurityCheckSlide({ icon="error" width={20} height={20} - style={{ color: "#F04438", flexShrink: 0 }} + style={{ color: "var(--c-danger)", flexShrink: 0 }} /> {i18n.t( diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css b/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css index afad117774..519ec608e0 100644 --- a/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.module.css @@ -28,15 +28,15 @@ .selectionBox { position: absolute; - border: 2px dashed #3b82f6; - background-color: rgba(59, 130, 246, 0.1); + border: 2px dashed var(--c-primary); + background-color: color-mix(in srgb, var(--c-primary) 10%, transparent); pointer-events: none; } .dropIndicator { position: absolute; width: 4px; - background-color: rgba(96, 165, 250, 0.8); + background-color: color-mix(in srgb, var(--c-primary) 80%, transparent); border-radius: 2px; pointer-events: none; } @@ -50,8 +50,8 @@ position: absolute; top: -8px; right: -8px; - background-color: #3b82f6; - color: #ffffff; + background-color: var(--c-primary); + color: var(--c-text-on-primary); border-radius: 50%; width: 32px; height: 32px; diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx index 472fc141d7..f3730a9399 100644 --- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx +++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx @@ -241,7 +241,7 @@ const FileThumbnail = ({ onToggleFile(file.id)} - color="var(--checkbox-checked-bg)" + color="var(--c-primary)" /> ) : (
@@ -372,7 +372,7 @@ const FileThumbnail = ({ objectFit: "contain", borderRadius: 0, background: "#ffffff", - border: "1px solid var(--border-default)", + border: "1px solid var(--c-border)", display: "block", marginLeft: "auto", marginRight: "auto", diff --git a/frontend/editor/src/core/components/pageEditor/PageEditor.module.css b/frontend/editor/src/core/components/pageEditor/PageEditor.module.css index 3be018ffa2..fe8660a04a 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditor.module.css +++ b/frontend/editor/src/core/components/pageEditor/PageEditor.module.css @@ -21,13 +21,13 @@ @keyframes pageMovedHighlight { 0% { - background-color: rgba(59, 130, 246, 0.32); + background-color: color-mix(in srgb, var(--c-primary) 32%, transparent); } 60% { - background-color: rgba(59, 130, 246, 0.12); + background-color: color-mix(in srgb, var(--c-primary) 12%, transparent); } 100% { - background-color: rgba(59, 130, 246, 0); + background-color: color-mix(in srgb, var(--c-primary) 0%, transparent); } } @@ -63,7 +63,7 @@ /* Action styles */ .actionRow:hover { - background: var(--hover-bg); + background: var(--c-hover); } .actionDanger { @@ -72,7 +72,7 @@ .actionsDivider { height: 1px; - background: var(--border-default); + background: var(--c-border); margin: 4px 0; } @@ -86,7 +86,7 @@ .unsupportedPill { margin-left: 1.75rem; - background: #6b7280; + background: var(--c-text-subtle); color: white; padding: 4px 8px; border-radius: 12px; diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx index 96762a9203..fe73984fef 100644 --- a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx @@ -121,8 +121,8 @@ const PageEditorControls = ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, boxShadow: "0 -2px 8px rgba(0,0,0,0.04)", - backgroundColor: "var(--bg-toolbar)", - border: "1px solid var(--border-default)", + backgroundColor: "var(--c-bg-raised)", + border: "1px solid var(--c-border)", borderRadius: "16px 16px 0 0", pointerEvents: "auto", minWidth: 360, diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css index f80bebaa26..1ecd05808b 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -31,7 +31,7 @@ .rightCol { width: 8rem; - border-left: 0.0625rem solid var(--border-default); + border-left: 0.0625rem solid var(--c-border); padding-left: 0.75rem; display: flex; flex-direction: column; @@ -47,15 +47,15 @@ width: 100%; border-radius: 1.25rem; border: 0.0625rem solid var(--bulk-card-border); - background-color: var(--bulk-card-bg); - color: var(--text-primary); + background-color: var(--c-surface); + color: var(--c-text); transition: all 0.2s ease; min-height: 2rem; } .operatorChip:hover:not(:disabled) { border-color: var(--bulk-card-hover-border); - background-color: var(--hover-bg); + background-color: var(--c-hover); transform: translateY(-0.0625rem); box-shadow: var(--shadow-sm); } @@ -70,24 +70,12 @@ cursor: not-allowed; } -:global([data-mantine-color-scheme="dark"]) .operatorChip { - background-color: var(--bulk-card-bg); - border-color: var(--bulk-card-border); - color: var(--text-primary); -} - -:global([data-mantine-color-scheme="dark"]) .operatorChip:hover:not(:disabled) { - background-color: var(--hover-bg); - border-color: var(--bulk-card-hover-border); - color: var(--text-primary); -} - .dropdownHeader { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; - border-bottom: 0.0625rem solid var(--border-default); + border-bottom: 0.0625rem solid var(--c-border); margin-bottom: 0.5rem; } @@ -111,7 +99,7 @@ } .chevron { - color: var(--text-muted); + color: var(--c-text-subtle); } /* Icon-based chevrons */ @@ -151,13 +139,13 @@ .selectedList { max-height: 8rem; overflow: auto; - background-color: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background-color: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; padding: 0.5rem 0.75rem; margin-top: 0.5rem; min-width: 24rem; - color: var(--text-primary); + color: var(--c-text); } .selectedText { @@ -175,7 +163,7 @@ justify-content: space-between; align-items: center; padding: 0.75rem; - border-bottom: 0.0625rem solid var(--border-default); + border-bottom: 0.0625rem solid var(--c-border); margin-bottom: 0.5rem; } @@ -193,26 +181,18 @@ } .advancedItem:hover { - background-color: var(--hover-bg); -} - -:global([data-mantine-color-scheme="dark"]) .advancedItem:hover { - background-color: var(--hover-bg); + background-color: var(--c-hover); } .advancedCard { - background-color: var(--bulk-card-bg); + background-color: var(--c-surface); border: none; border-radius: 0.75rem; padding: 0.25rem; margin-bottom: 0.5rem; width: 100%; box-sizing: border-box; - color: var(--text-primary); -} - -:global([data-mantine-color-scheme="dark"]) .advancedCard { - background-color: var(--bulk-card-bg); + color: var(--c-text); } .inputGroup { @@ -226,24 +206,17 @@ .applyButton { min-width: 4rem; flex-shrink: 0; -} - -/* Style inputs and buttons within advanced cards to match bg-raised */ -.advancedCard :global(.mantine-NumberInput-input) { - background-color: var(--bg-raised) !important; - border-color: var(--border-default) !important; - color: var(--text-primary) !important; -} - +} /* Style inputs and buttons within advanced cards to match bg-raised */ +.advancedCard :global(.mantine-NumberInput-input), .advancedCard :global(.mantine-Button-root) { - background-color: var(--bg-raised) !important; - border-color: var(--border-default) !important; - color: var(--text-primary) !important; + background-color: var(--c-surface-raised) !important; + border-color: var(--c-border) !important; + color: var(--c-text) !important; } .advancedCard :global(.mantine-Button-root:hover) { - background-color: var(--hover-bg) !important; - border-color: var(--border-strong) !important; + background-color: var(--c-hover) !important; + border-color: var(--c-border-strong) !important; } /* Error helper text above the input */ @@ -254,8 +227,8 @@ /* Compact error container for inline tool settings */ .errorCompact { - background-color: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background-color: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; padding: 0.5rem 0.75rem; margin-top: 0.5rem; @@ -271,11 +244,6 @@ overflow: hidden; } -/* Dark-mode adjustments */ -:global([data-mantine-color-scheme="dark"]) .selectedList { - background-color: var(--bg-raised); -} - /* Small screens: allow the section to shrink instead of enforcing a large min width */ @media (max-width: 480px) { .panelGroup, @@ -290,16 +258,16 @@ .panelContainer { max-height: 95vh; overflow: auto; - background-color: var(--bulk-panel-bg); - color: var(--text-primary); + background-color: var(--c-surface); + color: var(--c-text); border-radius: 0.5rem; } /* Override Mantine Popover dropdown background */ :global(.mantine-Popover-dropdown) { - background-color: var(--bulk-panel-bg) !important; + background-color: var(--c-surface) !important; border-color: var(--bulk-card-border) !important; - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Override Mantine Switch outline */ diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx index d2ff79ad97..682a134268 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx @@ -17,7 +17,7 @@ const OperatorsSection = ({ return (
- + {t("bulkSelection.keywords.title", "Keywords")}: diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx index 2e5513a7c8..f0add9d978 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx @@ -44,7 +44,7 @@ const PageSelectionInput = ({ icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" - style={{ color: "var(--text-instruction)" }} + style={{ color: "var(--c-accent-fg)" }} /> {t("bulkSelection.pageSelection.title", "Page Selection")} @@ -53,7 +53,7 @@ const PageSelectionInput = ({ {typeof advancedOpened === "boolean" && ( - + {t("bulkSelection.advanced.title", "Advanced")} - + {title} {error && ( diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.css b/frontend/editor/src/core/components/shared/AppConfigModal.css index 3f979258fb..46033eaf97 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.css +++ b/frontend/editor/src/core/components/shared/AppConfigModal.css @@ -63,7 +63,8 @@ } .modal-nav-scroll:hover { - scrollbar-color: rgba(128, 128, 128, 0.5) transparent; + scrollbar-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent) + transparent; } .modal-nav-scroll::-webkit-scrollbar { @@ -81,11 +82,11 @@ } .modal-nav-scroll:hover::-webkit-scrollbar-thumb { - background-color: rgba(128, 128, 128, 0.5); + background-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent); } .modal-nav-scroll::-webkit-scrollbar-thumb:hover { - background-color: rgba(128, 128, 128, 0.7); + background-color: color-mix(in srgb, var(--c-border-strong) 70%, transparent); } .modal-nav-section { @@ -131,7 +132,8 @@ } .modal-content-scroll:hover { - scrollbar-color: rgba(128, 128, 128, 0.5) transparent; + scrollbar-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent) + transparent; } .modal-content-scroll::-webkit-scrollbar { @@ -149,11 +151,11 @@ } .modal-content-scroll:hover::-webkit-scrollbar-thumb { - background-color: rgba(128, 128, 128, 0.5); + background-color: color-mix(in srgb, var(--c-border-strong) 50%, transparent); } .modal-content-scroll::-webkit-scrollbar-thumb:hover { - background-color: rgba(128, 128, 128, 0.7); + background-color: color-mix(in srgb, var(--c-border-strong) 70%, transparent); } .modal-header { @@ -212,8 +214,8 @@ bottom: 0; left: 0; right: 0; - background: var(--modal-content-bg); - border-top: 1px solid var(--modal-header-border); + background: var(--c-surface); + border-top: 1px solid var(--c-border-subtle); padding: 1rem 2rem; margin: 0 -2rem; margin-bottom: -1rem; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 1a9485cce8..9ad7963b6f 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -165,13 +165,13 @@ const AppConfigModalInner: React.FC = ({ const colors = useMemo( () => ({ - navBg: "var(--modal-nav-bg)", - sectionTitle: "var(--modal-nav-section-title)", + navBg: "var(--c-bg-raised)", + sectionTitle: "var(--c-text-subtle)", navItem: "var(--modal-nav-item)", - navItemActive: "var(--modal-nav-item-active)", - navItemActiveBg: "var(--modal-nav-item-active-bg)", - contentBg: "var(--modal-content-bg)", - headerBorder: "var(--modal-header-border)", + navItemActive: "var(--c-accent-fg)", + navItemActiveBg: "var(--c-primary-subtle)", + contentBg: "var(--c-surface)", + headerBorder: "var(--c-border-subtle)", }), [], ); diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css index c40080b1e9..bccf8fbceb 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.css +++ b/frontend/editor/src/core/components/shared/AppSwitch.css @@ -9,15 +9,15 @@ background: none; cursor: pointer; border-radius: var(--radius-sm); - color: var(--color-text-4); + color: var(--c-text-subtle); transition: background var(--motion-fast), color var(--motion-fast); } .app-switch-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-2); + background: var(--c-hover); + color: var(--c-text-muted); } .app-switch-icon { diff --git a/frontend/editor/src/core/components/shared/Badge.tsx b/frontend/editor/src/core/components/shared/Badge.tsx index dd15bec465..04ca973a8e 100644 --- a/frontend/editor/src/core/components/shared/Badge.tsx +++ b/frontend/editor/src/core/components/shared/Badge.tsx @@ -79,8 +79,8 @@ const Badge: React.FC = ({ // Default styling return { - background: "var(--tool-header-badge-bg)", - color: "var(--tool-header-badge-text)", + background: "var(--c-surface-raised)", + color: "var(--c-accent-fg)", }; }; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx index 24e6d881c3..4168996959 100644 --- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx @@ -78,7 +78,11 @@ export default function EditableSecretField({ )} {description && (

{description}

diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.tsx index a2003a7d61..b5330058c4 100644 --- a/frontend/editor/src/core/components/shared/ErrorBoundary.tsx +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.tsx @@ -117,7 +117,7 @@ export default class ErrorBoundary extends React.Component< style={{ fontSize: "0.75rem", overflow: "auto", - backgroundColor: "#f5f5f5", + backgroundColor: "var(--c-surface-sunken)", padding: "1rem", borderRadius: "4px", maxHeight: "300px", diff --git a/frontend/editor/src/core/components/shared/FileCard.tsx b/frontend/editor/src/core/components/shared/FileCard.tsx index c731092459..e43dbeeb1c 100644 --- a/frontend/editor/src/core/components/shared/FileCard.tsx +++ b/frontend/editor/src/core/components/shared/FileCard.tsx @@ -74,7 +74,7 @@ const FileCard = ({ diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx index 735d633fee..00eb8b7c56 100644 --- a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx @@ -54,8 +54,8 @@ export const FileDropdownMenu: React.FC = ({ (
{isGoogleDriveEnabled && ( ( aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")} style={ isWatchedFoldersActive - ? { backgroundColor: "var(--active-bg)" } + ? { backgroundColor: "var(--c-active)" } : undefined } > @@ -1197,7 +1197,7 @@ const FileSidebar = forwardRef( {!stubsLoaded ? (
- +
) : filteredFileStubs.length > 0 ? (
diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css index 09c0c5e8eb..4d742f7be6 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css @@ -17,7 +17,7 @@ background: transparent; } .file-sidebar-file-list::-webkit-scrollbar-thumb { - background: rgb(var(--border)); + background: var(--c-border); border-radius: 2px; } @@ -63,15 +63,15 @@ } .file-sidebar-file-item:hover:not(.selected) { - background-color: rgba(59, 130, 246, 0.06); + background-color: color-mix(in srgb, var(--c-primary) 6%, transparent); } .file-sidebar-file-item.selected { - background-color: rgba(59, 130, 246, 0.12); + background-color: color-mix(in srgb, var(--c-primary) 12%, transparent); } .file-sidebar-file-item.active:not(.selected) { - background-color: rgba(59, 130, 246, 0.06); + background-color: color-mix(in srgb, var(--c-primary) 6%, transparent); } /* Icon wrapper */ @@ -95,7 +95,7 @@ width: 20px; height: 20px; border-radius: 5px; - border: 1.5px solid var(--border-hover, var(--border-strong)); + border: 1.5px solid var(--c-border-strong); } .file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover { @@ -116,7 +116,7 @@ width: 20px; height: 20px; border-radius: 5px; - background-color: #3b82f6; + background-color: var(--c-primary); display: flex; align-items: center; justify-content: center; @@ -124,7 +124,7 @@ } .file-sidebar-check-svg { - color: white; + color: var(--c-text-on-primary); flex-shrink: 0; } @@ -179,14 +179,14 @@ display: block; font-size: 13px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-sidebar-file-item.selected .file-sidebar-file-name { - color: #3b82f6; + color: var(--c-accent-fg); } .file-sidebar-file-meta-row { @@ -199,7 +199,7 @@ .file-sidebar-file-meta { font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -211,7 +211,7 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--accent-interactive, #6366f1); + color: var(--c-primary); } /* ---- Folder membership tags ---- */ @@ -250,7 +250,7 @@ font-size: 10px; font-weight: 600; line-height: 1.2; - color: var(--text-secondary); + color: var(--c-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -260,7 +260,7 @@ font-size: 10px; font-weight: 600; line-height: 1; - color: var(--text-muted); + color: var(--c-text-subtle); cursor: default; flex-shrink: 0; } @@ -277,7 +277,7 @@ background: transparent; border-radius: 4px; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; opacity: 0; transition: @@ -297,48 +297,45 @@ bottom: 0; z-index: 2; /* solid base + green tint overlay so content behind doesn't bleed through */ - background-color: var(--bg-toolbar); + background-color: var(--c-bg-raised); background-image: linear-gradient( - rgba(34, 197, 94, 0.1), - rgba(34, 197, 94, 0.1) + color-mix(in srgb, var(--c-success) 10%, transparent), + color-mix(in srgb, var(--c-success) 10%, transparent) ); } .file-sidebar-file-item.viewed .file-sidebar-file-name { - color: #22c55e; + color: var(--c-success); } .file-sidebar-file-item.viewed .file-sidebar-file-check { - background-color: #22c55e; + background-color: var(--c-success); } /* viewed takes precedence over selected */ .file-sidebar-file-item.viewed.selected { - background-color: var(--bg-toolbar); + background-color: var(--c-bg-raised); background-image: linear-gradient( - rgba(34, 197, 94, 0.1), - rgba(34, 197, 94, 0.1) + color-mix(in srgb, var(--c-success) 10%, transparent), + color-mix(in srgb, var(--c-success) 10%, transparent) ); } /* Always show eye for the currently viewed file */ .file-sidebar-file-item.viewed .file-sidebar-eye-btn { opacity: 1; - color: #22c55e; + color: var(--c-success); } .file-sidebar-eye-btn:hover { - color: var(--text-primary); + color: var(--c-text); } /* Eye open: shown by default */ .file-sidebar-eye-open { display: block !important; } -.file-sidebar-eye-closed { - display: none !important; -} - +.file-sidebar-eye-closed, /* On hover over a viewed item: swap to eye-closed */ .file-sidebar-file-item.viewed:hover .file-sidebar-eye-open { display: none !important; @@ -359,7 +356,7 @@ background: transparent; border-radius: 4px; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0; opacity: 0; transition: @@ -376,14 +373,14 @@ } .file-sidebar-kebab-btn:hover { - color: var(--text-primary); + color: var(--c-text); } /* ---- Date group headers ---- */ .file-sidebar-date-group-header { font-size: 11px; font-weight: 500; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0 10px 2px 10px; margin-top: 8px; user-select: none; @@ -410,13 +407,13 @@ .file-sidebar-empty-text { font-size: 12px; - color: var(--text-muted); + color: var(--c-text-subtle); margin: 0 0 2px 0; } .file-sidebar-empty-hint { font-size: 11px; - color: var(--text-muted); + color: var(--c-text-subtle); opacity: 0.7; margin: 0; } @@ -426,8 +423,8 @@ position: fixed; z-index: 9999; transform: translateY(-50%); - background: var(--bg-surface, #fff); - border: 1px solid var(--border-subtle); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); padding: 4px; diff --git a/frontend/editor/src/core/components/shared/Footer.tsx b/frontend/editor/src/core/components/shared/Footer.tsx index 2fb7312fba..8ae0d3d4c1 100644 --- a/frontend/editor/src/core/components/shared/Footer.tsx +++ b/frontend/editor/src/core/components/shared/Footer.tsx @@ -54,8 +54,8 @@ export default function Footer({
= ({ disabled={action.disabled} onClick={action.onClick} aria-label={action.label} - style={{ color: action.color || "var(--text-secondary)" }} + style={{ color: action.color || "var(--c-text-muted)" }} data-tour={action.dataTour} > {action.icon} diff --git a/frontend/editor/src/core/components/shared/LandingPage.css b/frontend/editor/src/core/components/shared/LandingPage.css index 8762c1368d..57729a806a 100644 --- a/frontend/editor/src/core/components/shared/LandingPage.css +++ b/frontend/editor/src/core/components/shared/LandingPage.css @@ -10,7 +10,7 @@ font-size: 2rem; font-weight: 700; line-height: 1.25; - color: var(--text-primary); + color: var(--c-text); } .landing-subtitle { @@ -20,7 +20,7 @@ font-size: 0.9375rem; line-height: 1.5; max-width: 28rem; - color: var(--text-secondary); + color: var(--c-text-muted); } /* ── Document stack ──────────────────────────────────────── */ @@ -49,7 +49,7 @@ width: 128px; height: 160px; transform-origin: bottom center; - border: 1px solid #e5e7eb; + border: 1px solid var(--c-illustration-line); box-shadow: var(--landing-doc-shadow-back-idle); } @@ -103,10 +103,10 @@ /* Bars — static light colours, never change with theme */ .landing-bar { border-radius: 9999px; - background-color: #e5e7eb; + background-color: var(--c-illustration-line); } .landing-bar--strong { - background-color: #d1d5db; + background-color: var(--c-illustration-line-strong); } /* ── Action buttons ──────────────────────────────────────── */ @@ -121,27 +121,27 @@ .landing-btn-secondary { border-radius: 0.75rem !important; font-weight: 600 !important; - border-color: var(--landing-button-border, var(--border-default)) !important; - background-color: var(--landing-button-bg, var(--bg-surface)) !important; - color: var(--landing-button-color, var(--text-primary)) !important; + border-color: var(--landing-button-border, var(--c-border)) !important; + background-color: var(--landing-button-bg, var(--c-surface)) !important; + color: var(--c-accent-fg, var(--c-text)) !important; } .landing-btn-secondary:hover { background-color: var( --landing-button-hover-bg, - var(--landing-button-bg, var(--bg-surface)) + var(--landing-button-bg, var(--c-surface)) ) !important; } /* Icon-only variant: accent colour instead of button text colour */ .landing-btn-icon { - color: var(--accent-interactive) !important; + color: var(--c-primary) !important; } /* Dropzone accept/reject outlines. Mantine 8 no longer supports nested * `&[data-accept]` selectors inside the `styles` prop object, so these are * plain CSS attribute selectors on a class applied to the Dropzone root. */ .landing-dropzone[data-accept] { - outline: 2px dashed var(--accent-interactive); + outline: 2px dashed var(--c-primary); outline-offset: 4px; } .landing-dropzone[data-reject] { diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.module.css b/frontend/editor/src/core/components/shared/LanguageSelector.module.css index 8f2248687b..68c1d672bf 100644 --- a/frontend/editor/src/core/components/shared/LanguageSelector.module.css +++ b/frontend/editor/src/core/components/shared/LanguageSelector.module.css @@ -6,7 +6,7 @@ } .languageItem { - border-right: 2px solid var(--mantine-color-gray-3); + border-right: 2px solid var(--c-border); } .languageItem:nth-child(4n) { @@ -20,7 +20,7 @@ } .languageItem:nth-child(4n) { - border-right: 2px solid var(--mantine-color-gray-3); + border-right: 2px solid var(--c-border); } .languageItem:nth-child(2n) { @@ -34,7 +34,7 @@ } .languageItem:nth-child(4n) { - border-right: 2px solid var(--mantine-color-gray-3); + border-right: 2px solid var(--c-border); } .languageItem:nth-child(3n) { @@ -42,23 +42,12 @@ } } -/* Dark theme support */ -[data-mantine-color-scheme="dark"] .languageItem { - border-right-color: var(--mantine-color-dark-3); -} - +/* Dark theme: divider colour comes from the adaptive --c-border token on the + base rules; only the layout quirk (no right border on 4n) differs. */ [data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) { border-right: none; } -[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) { - border-right-color: var(--mantine-color-dark-3); -} - -[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) { - border-right-color: var(--mantine-color-dark-3); -} - /* Responsive text visibility */ .languageText { display: none; diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.tsx b/frontend/editor/src/core/components/shared/LanguageSelector.tsx index aad6c714d7..257f7cd0f5 100644 --- a/frontend/editor/src/core/components/shared/LanguageSelector.tsx +++ b/frontend/editor/src/core/components/shared/LanguageSelector.tsx @@ -91,10 +91,10 @@ const LanguageItem: React.FC = ({ ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" : undefined, color: disabled - ? "var(--text-muted)" + ? "var(--c-text-subtle)" : isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-3))" - : "var(--text-primary)", + : "var(--c-text)", }} > {label} diff --git a/frontend/editor/src/core/components/shared/ObscuredOverlay/ObscuredOverlay.module.css b/frontend/editor/src/core/components/shared/ObscuredOverlay/ObscuredOverlay.module.css index 5651993c4c..4f390cf066 100644 --- a/frontend/editor/src/core/components/shared/ObscuredOverlay/ObscuredOverlay.module.css +++ b/frontend/editor/src/core/components/shared/ObscuredOverlay/ObscuredOverlay.module.css @@ -12,7 +12,7 @@ padding: 16px; color: #ffffff; font-weight: 600; - background: rgba(16, 18, 27, 0.55); + background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); border: 1px solid rgba(255, 255, 255, 0.06); diff --git a/frontend/editor/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/editor/src/core/components/shared/PageEditorFileDropdown.tsx index fbe44c35c6..b15374d101 100644 --- a/frontend/editor/src/core/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/editor/src/core/components/shared/PageEditorFileDropdown.tsx @@ -197,8 +197,8 @@ export const PageEditorFileDropdown: React.FC = ({ = ({ marginTop: "0.5rem", cursor: "pointer", backgroundColor: "transparent", - borderTop: "1px solid var(--border-subtle)", + borderTop: "1px solid var(--c-border-subtle)", transition: "background-color 0.15s ease", }} onMouseEnter={(e) => { diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css index d193cc752e..3a919e24c9 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.css +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -17,7 +17,7 @@ width: 15px; height: 15px; border-radius: 4px; - color: var(--text-secondary); + color: var(--c-text-muted); background: color-mix(in srgb, currentColor 16%, transparent); pointer-events: auto; } diff --git a/frontend/editor/src/core/components/shared/ToolIcon.tsx b/frontend/editor/src/core/components/shared/ToolIcon.tsx index d0a1c82b9b..b1aa129304 100644 --- a/frontend/editor/src/core/components/shared/ToolIcon.tsx +++ b/frontend/editor/src/core/components/shared/ToolIcon.tsx @@ -14,7 +14,7 @@ interface ToolIconProps { export const ToolIcon: React.FC = ({ icon, opacity = 1, - color = "var(--tools-text-and-icon-color)", + color = "var(--c-text)", marginRight = "0.5rem", }) => { return ( diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.css b/frontend/editor/src/core/components/shared/ToolPanelHeader.css index f1d99d1913..6080586d07 100644 --- a/frontend/editor/src/core/components/shared/ToolPanelHeader.css +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.css @@ -18,7 +18,7 @@ flex: 1; min-width: 0; padding: 0.4rem 0.75rem 0.4rem 0.4rem; - border: 1px solid var(--border-subtle, var(--mantine-color-default-border)); + border: 1px solid var(--c-border-subtle, var(--mantine-color-default-border)); border-radius: 9999px; background: var(--mantine-color-body); text-align: left; @@ -70,7 +70,7 @@ so it doesn't read as a clashing lighter card on the dark toolbar. */ [data-mantine-color-scheme="dark"] .sui-panelhdr__bar { background: transparent; - border-color: var(--border-subtle, var(--mantine-color-default-border)); + border-color: var(--c-border-subtle, var(--mantine-color-default-border)); } [data-mantine-color-scheme="dark"] .sui-panelhdr__icon { diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 9c94bc4726..5403940751 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -383,7 +383,7 @@ export const Tooltip: React.FC = ({ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, visibility: positionReady ? "visible" : "hidden", opacity: positionReady ? 1 : 0, - color: "var(--text-primary)", + color: "var(--c-text)", ...containerStyle, }} className={`${styles["tooltip-container"]} ${isPinned ? styles.pinned : ""}`} diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx index 4a5e5b2404..f2c14f16fb 100644 --- a/frontend/editor/src/core/components/shared/UpdateModal.tsx +++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx @@ -291,7 +291,7 @@ const UpdateModal: React.FC = ({ = ({ style={{ borderTop: idx === 0 - ? "1px solid var(--border-subtle, var(--mantine-color-default-border))" + ? "1px solid var(--c-border-subtle, var(--mantine-color-default-border))" : undefined, borderBottom: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", padding: "10px 12px", }} > @@ -576,10 +576,10 @@ const UpdateModal: React.FC = ({ style={{ borderTop: index === 0 - ? "1px solid var(--border-subtle, var(--mantine-color-default-border))" + ? "1px solid var(--c-border-subtle, var(--mantine-color-default-border))" : undefined, borderBottom: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", }} > = ({ pb="sm" style={{ borderTop: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", }} > @@ -749,7 +749,7 @@ const UpdateModal: React.FC = ({ = ({ = ({ fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", - color: "var(--text-secondary)", + color: "var(--c-text-muted)", background: "rgb(var(--border))", padding: "3px 10px", borderRadius: "6px", diff --git a/frontend/editor/src/core/components/shared/textInput/TextInput.module.css b/frontend/editor/src/core/components/shared/textInput/TextInput.module.css index 72fa8abb7b..b5131b014f 100644 --- a/frontend/editor/src/core/components/shared/textInput/TextInput.module.css +++ b/frontend/editor/src/core/components/shared/textInput/TextInput.module.css @@ -15,7 +15,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); } .input { @@ -27,12 +27,12 @@ outline: none; box-shadow: none; transition: box-shadow 0.2s ease; - background-color: var(--input-bg); - color: var(--search-text-and-icon-color); + background-color: var(--c-input-bg); + color: var(--c-text-subtle); } .input::placeholder { - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); opacity: 1; } @@ -65,13 +65,9 @@ justify-content: center; font-size: 16px; transition: background-color 0.2s ease; - color: var(--search-text-and-icon-color); + color: var(--c-text-subtle); } .clearButton:hover { - background-color: rgba(0, 0, 0, 0.1); -} - -[data-mantine-color-scheme="dark"] .clearButton:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: var(--c-hover); } diff --git a/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css b/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css index e2b5ecca10..fa4023dc56 100644 --- a/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css +++ b/frontend/editor/src/core/components/shared/tooltip/Tooltip.module.css @@ -1,9 +1,9 @@ /* Tooltip Container */ .tooltip-container { position: fixed; - border: 0.0625rem solid var(--border-default); + border: 0.0625rem solid var(--c-border); border-radius: 0.75rem; - background-color: var(--bg-raised); + background-color: var(--c-surface-raised); box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05); @@ -15,25 +15,25 @@ transform 100ms ease-out; max-width: 50vh; max-height: 80vh; - color: var(--text-primary); + color: var(--c-text); display: flex; flex-direction: column; } /* Pinned tooltip indicator */ .tooltip-container.pinned { - border-color: var(--primary-color, #3b82f6); + border-color: var(--primary-color, var(--c-primary)); box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), - 0 0 0 0.125rem rgba(59, 130, 246, 0.1); + 0 0 0 0.125rem color-mix(in srgb, var(--c-primary) 10%, transparent); } /* Pinned tooltip header */ .tooltip-container.pinned .tooltip-header { - background-color: var(--primary-color, #3b82f6); + background-color: var(--primary-color, var(--c-primary)); color: white; - border-color: var(--primary-color, #3b82f6); + border-color: var(--primary-color, var(--c-primary)); } /* Close button */ @@ -42,10 +42,10 @@ top: 0.5rem; right: 0.5rem; font-size: 0.875rem; - background: var(--bg-raised); + background: var(--c-surface-raised); padding: 0.25rem; border-radius: 0.25rem; - border: 0.0625rem solid var(--border-default); + border: 0.0625rem solid var(--c-border); cursor: pointer; transition: background-color 0.2s ease, @@ -64,15 +64,15 @@ } .tooltip-pin-button:hover { - background-color: #ef4444 !important; - border-color: #ef4444 !important; + background-color: var(--c-danger) !important; + border-color: var(--c-danger) !important; } .tooltip-pin-button:focus, .tooltip-pin-button:focus-visible { outline: none; - border-color: var(--border-default) !important; - background-color: var(--bg-raised) !important; + border-color: var(--c-border) !important; + background-color: var(--c-surface-raised) !important; } /* Tooltip Header */ @@ -107,7 +107,7 @@ /* Tooltip Body */ .tooltip-body { padding: 1rem; - color: var(--text-primary) !important; + color: var(--c-text) !important; font-size: 0.875rem !important; line-height: 1.6 !important; overflow-y: auto; @@ -116,47 +116,49 @@ } .tooltip-body * { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Link styling within tooltips */ .tooltip-body a { - color: var(--link-color, #3b82f6) !important; + color: var(--link-color, var(--c-primary)) !important; text-decoration: underline; - text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + text-decoration-color: var( + --link-underline-color, + color-mix(in srgb, var(--c-primary) 30%, transparent) + ); transition: color 0.2s ease, text-decoration-color 0.2s ease; } .tooltip-body a:hover { - color: var(--link-hover-color, #2563eb) !important; + color: var(--link-hover-color, var(--c-primary-hover)) !important; text-decoration-color: var( --link-hover-underline-color, - rgba(37, 99, 235, 0.5) + color-mix(in srgb, var(--c-primary-hover) 50%, transparent) ); } - -.tooltip-container .tooltip-body { - color: var(--text-primary) !important; -} - +.tooltip-container .tooltip-body, .tooltip-container .tooltip-body * { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Ensure links maintain their styling */ .tooltip-container .tooltip-body a { - color: var(--link-color, #3b82f6) !important; + color: var(--link-color, var(--c-primary)) !important; text-decoration: underline; - text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3)); + text-decoration-color: var( + --link-underline-color, + color-mix(in srgb, var(--c-primary) 30%, transparent) + ); } .tooltip-container .tooltip-body a:hover { - color: var(--link-hover-color, #2563eb) !important; + color: var(--link-hover-color, var(--c-primary-hover)) !important; text-decoration-color: var( --link-hover-underline-color, - rgba(37, 99, 235, 0.5) + color-mix(in srgb, var(--c-primary-hover) 50%, transparent) ); } @@ -165,8 +167,8 @@ position: absolute; width: 0.5rem; height: 0.5rem; - background: var(--bg-raised); - border: 0.0625rem solid var(--border-default); + background: var(--c-surface-raised); + border: 0.0625rem solid var(--c-border); transform: rotate(45deg); } diff --git a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx index 08436799f9..28d18c4e4c 100644 --- a/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx +++ b/frontend/editor/src/core/components/shared/tooltip/TooltipContent.tsx @@ -17,13 +17,13 @@ export const TooltipContent: React.FC = ({
-
+
{tips ? ( <> {tips.map((tip, index) => ( @@ -35,8 +35,8 @@ export const TooltipContent: React.FC = ({
= ({

= ({ style={{ margin: "0", paddingLeft: "16px", - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontSize: "13px", }} > diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.css b/frontend/editor/src/core/components/toast/ToastRenderer.css index 6ecd9e3dfd..9a9bbbe609 100644 --- a/frontend/editor/src/core/components/toast/ToastRenderer.css +++ b/frontend/editor/src/core/components/toast/ToastRenderer.css @@ -75,26 +75,26 @@ /* Toast Alert Type Colors */ .toast-item--success { background: var(--color-green-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-green-400); } .toast-item--error { background: var(--color-red-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-red-400); } .toast-item--warning { background: var(--color-yellow-100); - color: var(--text-primary); + color: var(--c-text); border: 1px solid var(--color-yellow-400); } .toast-item--neutral { - background: var(--bg-surface); - color: var(--text-primary); - border: 1px solid var(--border-default); + background: var(--c-surface); + color: var(--c-text); + border: 1px solid var(--c-border); } /* Toast Header Row */ @@ -146,7 +146,7 @@ border-radius: 999px; border: none; background: transparent; - color: var(--text-secondary); + color: var(--c-text-muted); cursor: pointer; display: flex; align-items: center; @@ -166,7 +166,7 @@ .toast-progress-container { margin-top: 8px; height: 6px; - background: var(--bg-muted); + background: var(--c-surface-sunken); border-radius: 999px; overflow: hidden; } @@ -217,21 +217,21 @@ } .toast-action-button--success { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-green-400); } .toast-action-button--error { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-red-400); } .toast-action-button--warning { - color: var(--text-primary); + color: var(--c-text); border-color: var(--color-yellow-400); } .toast-action-button--neutral { - color: var(--text-primary); - border-color: var(--border-default); + color: var(--c-text); + border-color: var(--c-border); } diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 41110ac034..73ec5db69f 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -168,7 +168,7 @@ 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" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} + className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--c-bg-raised)] border-l border-[var(--c-border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} style={{ width: computedWidth(), padding: "0", diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index e9cbb9b1b2..8a2d17f3fa 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -4,8 +4,8 @@ align-items: center; gap: 2px; padding: 4px 8px; - border-bottom: 1px solid var(--border-subtle); - background-color: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background-color: var(--c-bg-raised); flex-shrink: 0; } @@ -13,115 +13,107 @@ .tool-panel__fullscreen-surface-inner { --fullscreen-bg-surface-1: color-mix( in srgb, - var(--bg-toolbar) 96%, + var(--c-bg-raised) 96%, transparent ); - --fullscreen-bg-surface-2: color-mix( - in srgb, - var(--bg-background) 90%, - transparent - ); - --fullscreen-bg-header: var(--bg-toolbar); - --fullscreen-bg-controls-1: var(--bg-toolbar); + --fullscreen-bg-surface-2: color-mix(in srgb, var(--c-bg) 90%, transparent); + --fullscreen-bg-header: var(--c-bg-raised); + --fullscreen-bg-controls-1: var(--c-bg-raised); --fullscreen-bg-controls-2: color-mix( in srgb, - var(--bg-toolbar) 95%, - var(--bg-background) - ); - --fullscreen-bg-body-1: color-mix( - in srgb, - var(--bg-background) 86%, - transparent + var(--c-bg-raised) 95%, + var(--c-bg) ); + --fullscreen-bg-body-1: color-mix(in srgb, var(--c-bg) 86%, transparent); --fullscreen-bg-body-2: color-mix( in srgb, - var(--bg-toolbar) 78%, + var(--c-bg-raised) 78%, transparent ); - --fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent); - --fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent); + --fullscreen-bg-group: color-mix( + in srgb, + var(--c-bg-raised) 82%, + transparent + ); + --fullscreen-bg-item: color-mix(in srgb, var(--c-bg-raised) 88%, transparent); --fullscreen-bg-list-item: color-mix( in srgb, - var(--bg-toolbar) 86%, + var(--c-bg-raised) 86%, transparent ); --fullscreen-bg-icon-detailed: color-mix( in srgb, - var(--bg-muted) 75%, + var(--c-surface-sunken) 75%, transparent ); --fullscreen-bg-icon-compact: color-mix( in srgb, - var(--bg-muted) 70%, + var(--c-surface-sunken) 70%, transparent ); --fullscreen-border-subtle-75: color-mix( in srgb, - var(--border-subtle) 75%, + var(--c-border-subtle) 75%, transparent ); --fullscreen-border-subtle-70: color-mix( in srgb, - var(--border-subtle) 70%, + var(--c-border-subtle) 70%, transparent ); --fullscreen-border-subtle-65: color-mix( in srgb, - var(--border-subtle) 65%, + var(--c-border-subtle) 65%, transparent ); --fullscreen-border-favorites: color-mix( in srgb, var(--special-color-favorites) 25%, - var(--border-subtle) + var(--c-border-subtle) ); --fullscreen-border-recommended: color-mix( in srgb, var(--special-color-recommended) 25%, - var(--border-subtle) + var(--c-border-subtle) ); --fullscreen-shadow-primary: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, + var(--shadow-color, color-mix(in srgb, black 55%, transparent)) 25%, transparent ); --fullscreen-shadow-secondary: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, + var(--shadow-color, color-mix(in srgb, black 35%, transparent)) 30%, transparent ); --fullscreen-shadow-group: color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, + var(--shadow-color, color-mix(in srgb, black 45%, transparent)) 18%, transparent ); --fullscreen-accent-hover: color-mix( in srgb, - var(--text-primary) 20%, - var(--border-subtle) + var(--c-text) 20%, + var(--c-border-subtle) ); --fullscreen-accent-selected: color-mix( in srgb, - var(--text-primary) 30%, - var(--border-subtle) - ); - --fullscreen-accent-ring: color-mix( - in srgb, - var(--text-primary) 15%, - transparent + var(--c-text) 30%, + var(--c-border-subtle) ); + --fullscreen-accent-ring: color-mix(in srgb, var(--c-text) 15%, transparent); --fullscreen-accent-list-bg: color-mix( in srgb, - var(--text-primary) 8%, - var(--bg-toolbar) + var(--c-text) 8%, + var(--c-bg-raised) ); --fullscreen-accent-list-border: color-mix( in srgb, - var(--text-primary) 20%, - var(--border-subtle) + var(--c-text) 20%, + var(--c-border-subtle) ); - --fullscreen-text-icon: var(--text-primary); - --fullscreen-text-icon-compact: var(--text-primary); + --fullscreen-text-icon: var(--c-text); + --fullscreen-text-icon-compact: var(--c-text); } .tool-panel { @@ -153,7 +145,7 @@ .tool-panel__collapsed-divider { height: 1px; - background: var(--border-subtle); + background: var(--c-border-subtle); margin: 0 0.5rem 8px; } @@ -178,7 +170,7 @@ border: 1px solid transparent; border-radius: 0.5rem; background: transparent; - color: var(--tools-text-and-icon-color); + color: var(--c-text); cursor: pointer; transition: background 120ms ease-out, @@ -198,8 +190,8 @@ .tool-panel__expand-btn { flex-shrink: 0; - color: var(--text-secondary) !important; - border-color: var(--border-subtle) !important; + color: var(--c-text-muted) !important; + border-color: var(--c-border-subtle) !important; } /* The collapse/expand toggle keeps a stable identity across the collapsed strip @@ -210,25 +202,25 @@ } .tool-panel__expand-btn svg { - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__expand-btn:hover { - color: var(--text-primary) !important; + color: var(--c-text) !important; border-color: var(--border) !important; } .tool-panel__collapsed-search-btn { flex-shrink: 0; - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__collapsed-search-btn svg { - color: var(--text-secondary) !important; + color: var(--c-text-muted) !important; } .tool-panel__collapsed-search-btn:hover { - color: var(--text-primary) !important; + color: var(--c-text) !important; } .tool-panel__back-btn { @@ -237,7 +229,7 @@ .tool-panel__back-bar { flex-shrink: 0; - border-bottom: 1px solid var(--border-subtle) !important; + border-bottom: 1px solid var(--c-border-subtle) !important; } .tool-panel--fullscreen-active { @@ -282,7 +274,7 @@ /* 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)); + border-top: 1px solid var(--c-border-subtle, var(--c-border)); } .tool-panel__between-search .search-input-container { @@ -292,7 +284,7 @@ /* Slightly recessed search field so it reads as distinct from the panel. */ .tool-panel__between-search input { - background-color: var(--bg-muted); + background-color: var(--c-surface-sunken); } ::view-transition-old(tool-rail) { @@ -334,7 +326,7 @@ } .tool-panel--fullscreen { - background: var(--bg-toolbar); + background: var(--c-bg-raised); } .tool-panel__placeholder { @@ -342,7 +334,7 @@ display: flex; align-items: center; justify-content: center; - color: var(--text-muted); + color: var(--c-text-subtle); font-size: 0.9rem; padding: 1.5rem; text-align: center; @@ -403,8 +395,8 @@ align-items: center; gap: 1rem; padding: 0.75rem 1.75rem; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); } .tool-panel__fullscreen-brand { @@ -428,8 +420,8 @@ align-items: center; gap: 1rem; padding: 0.75rem 1.75rem; - border-bottom: 1px solid var(--tool-panel-search-border-bottom); - background: var(--tool-panel-search-bg); + border-bottom: 1px solid var(--c-border); + background: var(--c-surface-sunken); } .tool-panel__fullscreen-controls .search-input-container { @@ -546,7 +538,10 @@ .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]):not(:disabled) { transform: translateY(-2px); border-color: var(--fullscreen-accent-hover); - box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14)); + box-shadow: var( + --shadow-xl, + 0 18px 34px color-mix(in srgb, black 14%, transparent) + ); } .tool-panel__fullscreen-item--selected { @@ -619,8 +614,8 @@ inset: 0; background: linear-gradient( 135deg, - color-mix(in srgb, var(--text-primary) 12%, transparent), - color-mix(in srgb, var(--text-primary) 4%, transparent) + color-mix(in srgb, var(--c-text) 12%, transparent), + color-mix(in srgb, var(--c-text) 4%, transparent) ); opacity: 0; transition: opacity 0.2s ease; @@ -630,8 +625,7 @@ .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon { transform: scale(1.08); - box-shadow: 0 4px 12px - color-mix(in srgb, var(--text-primary) 15%, transparent); + box-shadow: 0 4px 12px color-mix(in srgb, var(--c-text) 15%, transparent); } .tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) @@ -656,7 +650,7 @@ } .tool-panel__fullscreen-name { - color: var(--text-primary); + color: var(--c-text); font-size: 13px !important; font-weight: 500 !important; } @@ -753,8 +747,8 @@ inset: 0; background: linear-gradient( 135deg, - color-mix(in srgb, var(--text-primary) 10%, transparent), - color-mix(in srgb, var(--text-primary) 3%, transparent) + color-mix(in srgb, var(--c-text) 10%, transparent), + color-mix(in srgb, var(--c-text) 3%, transparent) ); opacity: 0; transition: opacity 0.2s ease; @@ -764,7 +758,7 @@ .tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon { transform: scale(1.06); - box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent); + box-shadow: 0 2px 8px color-mix(in srgb, var(--c-text) 12%, transparent); } .tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css index 2749abe537..58421faa4c 100644 --- a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css +++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.css @@ -1,10 +1,10 @@ .tool-panel-mode-prompt__modal { - background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 94%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 70%, transparent); box-shadow: 0 32px 64px color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, + var(--shadow-color, color-mix(in srgb, black 55%, transparent)) 20%, transparent ); max-width: min(46rem, 100%); @@ -22,8 +22,8 @@ gap: 1rem; background: linear-gradient( 145deg, - color-mix(in srgb, var(--bg-surface) 96%, transparent), - color-mix(in srgb, var(--bg-muted) 70%, transparent) + color-mix(in srgb, var(--c-surface) 96%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 70%, transparent) ); width: 100%; max-width: 19rem; @@ -31,28 +31,20 @@ .tool-panel-mode-prompt__card--sidebar { border: 1px solid - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, - var(--border-subtle) - ); + color-mix(in srgb, var(--c-primary) 18%, var(--c-border-subtle)); background: linear-gradient( 165deg, - color-mix(in srgb, var(--bg-surface) 96%, transparent), - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, - transparent - ) + color-mix(in srgb, var(--c-surface) 96%, transparent), + color-mix(in srgb, var(--c-primary) 8%, transparent) ); } .tool-panel-mode-prompt__preview { border-radius: 0.9rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 70%, transparent); background: linear-gradient( 135deg, - color-mix(in srgb, var(--bg-muted) 82%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 82%, transparent), transparent 75% ); padding: 0.75rem; @@ -76,54 +68,49 @@ gap: 0.45rem; background: linear-gradient( 180deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-muted) 72%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 72%, transparent) ); - border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 65%, transparent); } .tool-panel-mode-prompt__sidebar-search { height: 0.5rem; border-radius: 0.4rem; - background: color-mix(in srgb, var(--bg-background) 90%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent); + background: color-mix(in srgb, var(--c-bg) 90%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 60%, transparent); } .tool-panel-mode-prompt__sidebar-item { height: 0.55rem; border-radius: 0.35rem; - background: color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, - var(--bg-muted) - ); + background: color-mix(in srgb, var(--c-primary) 32%, var(--c-surface-sunken)); } .tool-panel-mode-prompt__sidebar-item--muted { - background: color-mix(in srgb, var(--bg-background) 88%, transparent); + background: color-mix(in srgb, var(--c-bg) 88%, transparent); } .tool-panel-mode-prompt__workspace { flex: 1; border-radius: 0.65rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 65%, transparent); padding: 0.5rem; display: grid; gap: 0.35rem; grid-template-rows: 1.4fr 0.6fr; background: linear-gradient( 160deg, - color-mix(in srgb, var(--bg-background) 94%, transparent), - color-mix(in srgb, var(--bg-muted) 68%, transparent) + color-mix(in srgb, var(--c-bg) 94%, transparent), + color-mix(in srgb, var(--c-surface-sunken) 68%, transparent) ); } .tool-panel-mode-prompt__workspace-page { border-radius: 0.45rem; - background: color-mix(in srgb, var(--bg-surface) 96%, transparent); - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); - box-shadow: inset 0 0 0 1px - color-mix(in srgb, var(--bg-background) 60%, transparent); + background: color-mix(in srgb, var(--c-surface) 96%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--c-bg) 60%, transparent); } .tool-panel-mode-prompt__workspace-page--secondary { @@ -151,11 +138,11 @@ .tool-panel-mode-prompt__legacy-card { border-radius: 0.45rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); background: linear-gradient( 150deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-background) 76%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-bg) 76%, transparent) ); height: 1.2rem; } @@ -185,11 +172,11 @@ .tool-panel-mode-prompt__fullscreen-card { border-radius: 0.45rem; - border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); + border: 1px solid color-mix(in srgb, var(--c-border-subtle) 55%, transparent); background: linear-gradient( 150deg, - color-mix(in srgb, var(--bg-muted) 88%, transparent), - color-mix(in srgb, var(--bg-background) 76%, transparent) + color-mix(in srgb, var(--c-surface-sunken) 88%, transparent), + color-mix(in srgb, var(--c-bg) 76%, transparent) ); height: 1.2rem; } @@ -210,20 +197,15 @@ } .tool-panel-mode-prompt__action:hover { - box-shadow: 0 10px 18px - color-mix( - in srgb, - var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, - transparent - ); + box-shadow: 0 10px 18px color-mix(in srgb, var(--c-primary) 25%, transparent); } .tool-panel-mode-prompt__maybe-later { - color: color-mix(in srgb, var(--text-secondary) 90%, var(--text-muted)); + color: color-mix(in srgb, var(--c-text-muted) 90%, var(--c-text-subtle)); } .tool-panel-mode-prompt__maybe-later:hover { - background: color-mix(in srgb, var(--bg-muted) 78%, transparent); + background: color-mix(in srgb, var(--c-surface-sunken) 78%, transparent); } @media (max-width: 600px) { diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 779e945b8c..6cda34aff2 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -37,7 +37,7 @@ const HEADER_TEXT_STYLE: React.CSSProperties = { padding: "0.25rem 0 0.35rem 0.5rem", textTransform: "uppercase", letterSpacing: "0.06em", - color: "var(--text-muted)", + color: "var(--c-text-subtle)", }; const SCROLLABLE_STYLE: React.CSSProperties = { flex: 1, @@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = { const CONTAINER_STYLE: React.CSSProperties = { display: "flex", flexDirection: "column", - background: "var(--bg-toolbar)", + background: "var(--c-bg-raised)", }; const toTitleCase = (s: string) => s.replace( diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css index 8a20a78340..d2ae85a88b 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.module.css @@ -16,7 +16,7 @@ } .containerBorder { - border: 1px solid var(--border-default, #333); + border: 1px solid var(--c-border); } /* Page thumbnail styles */ @@ -105,8 +105,8 @@ .gridTileSelected, .gridTileHovered { - border: 2px solid var(--mantine-primary-color-filled, #3b82f6); - background-color: rgba(59, 130, 246, 0.2); + border: 2px solid var(--c-primary); + background-color: color-mix(in srgb, var(--c-primary) 20%, transparent); } /* Preview header */ @@ -116,14 +116,14 @@ .divider { height: 1px; - background-color: var(--border-default, #333); + background-color: var(--c-border); margin-bottom: 8px; } .previewLabel { font-size: 14px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); text-align: center; } diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index fe92d1ff42..6e35b286a9 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -220,7 +220,7 @@ export default function PageNumberPreview({ width: "100%", aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, backgroundColor: pageThumbnail ? "white" : "rgba(255,255,255,0.03)", - border: "1px solid var(--border-default, #333)", + border: "1px solid var(--c-border, #333)", overflow: "hidden" as const, }), [pageSize, pageThumbnail], diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css b/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css index 7e740f6f51..dccf897eaa 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.module.css @@ -16,7 +16,7 @@ } .containerBorder { - border: 1px solid var(--border-default, #333); + border: 1px solid var(--c-border); } /* Page thumbnail styles */ @@ -95,7 +95,7 @@ .gridTileSelected, .gridTileHovered { - border: 2px solid var(--mantine-primary-color-filled, #3b82f6); + border: 2px solid var(--c-primary); } /* Preview header */ @@ -105,14 +105,14 @@ .divider { height: 1px; - background-color: var(--border-default, #333); + background-color: var(--c-border); margin-bottom: 8px; } .previewLabel { font-size: 14px; font-weight: 500; - color: var(--text-primary); + color: var(--c-text); text-align: center; } @@ -127,7 +127,7 @@ /* Information text container */ .informationContainer { - background-color: var(--information-text-bg); + background-color: var(--c-surface); padding: 2px; padding-left: 8px; padding-right: 8px; diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts b/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts index 97120c0fcf..8ac3c73551 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreviewUtils.ts @@ -280,7 +280,7 @@ export function computeStampPreviewStyle( width: "100%", aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`, backgroundColor: hasPageThumbnail ? "white" : "rgba(255,255,255,0.03)", - border: "1px solid var(--border-default, #333)", + border: "1px solid var(--c-border, #333)", overflow: "hidden", }, item: { diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index 322d2d87fc..8ffbfeda81 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -191,11 +191,9 @@ export default function AutomationEntry({ className="tool-button" style={{ borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + color: "var(--c-text)", overflow: "visible", - backgroundColor: shouldShowMenu - ? "var(--automation-entry-hover-bg)" - : undefined, + backgroundColor: shouldShowMenu ? "var(--c-hover)" : undefined, }} > {buttonContent} diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx index e59996ab8f..4f63a80d3c 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx @@ -153,7 +153,7 @@ export default function AutomationRun({ style={{ width: 16, height: 16, - border: "2px solid #ccc", + border: "2px solid var(--c-border)", borderRadius: "50%", }} /> diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.tsx index ad67ed4aa9..e2fe54deb0 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolList.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolList.tsx @@ -148,7 +148,7 @@ export default function ToolList({ borderTop: "none", borderRadius: "0 0 var(--mantine-radius-lg) var(--mantine-radius-lg)", - backgroundColor: "var(--active-bg)", + backgroundColor: "var(--c-active)", padding: "var(--mantine-spacing-xs)", }} > diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx index 4311c5f6ca..aaab1cb93e 100644 --- a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx @@ -75,7 +75,7 @@ const CertificateTypeSettings = ({ if (!hasAlternativeSources) { return ( -

+
{t( "certSign.source.noOtherSources", "No other certificate sources are available.", diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx index 2b128ebd11..0f21335833 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx @@ -317,9 +317,9 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => { onClick={handleAddToActiveFiles} fullWidth style={{ - backgroundColor: "var(--landing-inner-paper-bg)", - color: "var(--btn-open-file)", - border: "1px solid var(--landing-inner-paper-border)", + backgroundColor: "var(--c-surface-raised)", + color: "var(--c-primary)", + border: "1px solid var(--c-border)", }} > {t("certSign.collab.signRequest.addToFiles", "Add to Active Files")} diff --git a/frontend/editor/src/core/components/tools/compare/compareView.css b/frontend/editor/src/core/components/tools/compare/compareView.css index 7a86c69d4a..aaa703fcc1 100644 --- a/frontend/editor/src/core/components/tools/compare/compareView.css +++ b/frontend/editor/src/core/components/tools/compare/compareView.css @@ -5,19 +5,14 @@ .compare-dropdown-sticky { position: sticky; z-index: 2; - background: var(--compare-page-label-bg); + background: var(--c-surface-sunken); color: var(--compare-page-label-fg); font-size: 0.75rem; padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--c-border-subtle); pointer-events: none; } -[data-mantine-color-scheme="dark"] .compare-dropdown-sticky { - background: var(--compare-page-label-bg); - color: var(--compare-page-label-fg); - border-bottom: 1px solid var(--border-default); -} .compare-workbench { display: flex; flex-direction: column; @@ -91,9 +86,9 @@ position: sticky; top: 0; z-index: 10; - background: var(--bg-toolbar); + background: var(--c-bg-raised); backdrop-filter: blur(8px); - border-bottom: 1px solid var(--border-default); + border-bottom: 1px solid var(--c-border); padding: 0.5rem; margin: -0.5rem -0.5rem 0.5rem -0.5rem; } @@ -164,21 +159,13 @@ margin: 0 !important; border-top-left-radius: 8px !important; border-top-right-radius: 8px !important; -} - -/* Style the dropdown container */ -.compare-changes-select .mantine-Combobox-dropdown { - border: 1px solid var(--border-subtle) !important; - border-radius: 8px !important; - box-shadow: var(--shadow-md) !important; - background-color: var(--bg-surface) !important; -} - +} /* Style the dropdown container */ +.compare-changes-select .mantine-Combobox-dropdown, .compare-changes-select--comparison .mantine-Combobox-dropdown { - border: 1px solid var(--border-subtle) !important; + border: 1px solid var(--c-border-subtle) !important; border-radius: 8px !important; box-shadow: var(--shadow-md) !important; - background-color: var(--bg-surface) !important; + background-color: var(--c-surface) !important; } /* Custom scrollbar for ScrollArea */ @@ -187,18 +174,18 @@ } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track { - background: var(--bg-muted) !important; + background: var(--c-surface-sunken) !important; border-radius: 3px !important; } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { - background: var(--border-strong) !important; + background: var(--c-border-strong) !important; border-radius: 3px !important; } .compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { - background: var(--text-muted) !important; + background: var(--c-text-subtle) !important; } .compare-changes-select--comparison @@ -208,27 +195,21 @@ .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track { - background: var(--bg-muted) !important; + background: var(--c-surface-sunken) !important; border-radius: 3px !important; } .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { - background: var(--border-strong) !important; + background: var(--c-border-strong) !important; border-radius: 3px !important; } .compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { - background: var(--text-muted) !important; -} - -/* Style the dropdown options */ -.compare-changes-select .mantine-Combobox-option { - font-size: 0.875rem !important; - padding: 8px 12px !important; -} - + background: var(--c-text-subtle) !important; +} /* Style the dropdown options */ +.compare-changes-select .mantine-Combobox-option, .compare-changes-select--comparison .mantine-Combobox-option { font-size: 0.875rem !important; padding: 8px 12px !important; @@ -240,27 +221,16 @@ .compare-changes-select--comparison .mantine-Combobox-option:hover { background-color: var(--spdf-compare-added-badge-bg) !important; -} - -/* Style the search input */ -.compare-changes-select .mantine-Combobox-search { - font-size: 0.875rem !important; - padding: 8px 12px !important; - border-bottom: 1px solid var(--border-subtle) !important; -} - +} /* Style the search input */ +.compare-changes-select .mantine-Combobox-search, .compare-changes-select--comparison .mantine-Combobox-search { font-size: 0.875rem !important; padding: 8px 12px !important; - border-bottom: 1px solid var(--border-subtle) !important; + border-bottom: 1px solid var(--c-border-subtle) !important; } - -.compare-changes-select .mantine-Combobox-search::placeholder { - color: var(--text-muted) !important; -} - +.compare-changes-select .mantine-Combobox-search::placeholder, .compare-changes-select--comparison .mantine-Combobox-search::placeholder { - color: var(--text-muted) !important; + color: var(--c-text-subtle) !important; } /* Style the chevron - ensure proper coloring */ @@ -273,29 +243,49 @@ /* Flash/pulse highlight for navigated change */ @keyframes compare-flash { 0% { - outline: 4px solid rgba(255, 235, 59, 0); - box-shadow: 0 0 0 rgba(255, 235, 59, 0); - background-color: rgba(255, 235, 59, 0.2) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 0%, transparent); + box-shadow: 0 0 0 color-mix(in srgb, var(--c-highlight) 0%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 20%, + transparent + ) !important; } 25% { - outline: 4px solid rgba(255, 235, 59, 1); - box-shadow: 0 0 20px rgba(255, 235, 59, 0.8); - background-color: rgba(255, 235, 59, 0.4) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 100%, transparent); + box-shadow: 0 0 20px color-mix(in srgb, var(--c-highlight) 80%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 40%, + transparent + ) !important; } 50% { - outline: 4px solid rgba(255, 235, 59, 1); - box-shadow: 0 0 30px rgba(255, 235, 59, 0.9); - background-color: rgba(255, 235, 59, 0.5) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 100%, transparent); + box-shadow: 0 0 30px color-mix(in srgb, var(--c-highlight) 90%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 50%, + transparent + ) !important; } 75% { - outline: 4px solid rgba(255, 235, 59, 0.8); - box-shadow: 0 0 15px rgba(255, 235, 59, 0.6); - background-color: rgba(255, 235, 59, 0.3) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 80%, transparent); + box-shadow: 0 0 15px color-mix(in srgb, var(--c-highlight) 60%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 30%, + transparent + ) !important; } 100% { - outline: 4px solid rgba(255, 235, 59, 0); - box-shadow: 0 0 0 rgba(255, 235, 59, 0); - background-color: rgba(255, 235, 59, 0) !important; + outline: 4px solid color-mix(in srgb, var(--c-highlight) 0%, transparent); + box-shadow: 0 0 0 color-mix(in srgb, var(--c-highlight) 0%, transparent); + background-color: color-mix( + in srgb, + var(--c-highlight) 0%, + transparent + ) !important; } } @@ -304,14 +294,18 @@ z-index: 1000; position: relative; /* Bonus: temporarily override red/green to yellow during flash for clarity */ - background-color: rgba(255, 235, 59, 0.5) !important; + background-color: color-mix( + in srgb, + var(--c-highlight) 50%, + transparent + ) !important; } /* Union overlay for group flash */ .compare-diff-flash-overlay { animation: compare-flash 1.5s ease-in-out 1; z-index: 999; - background-color: rgba(255, 235, 59, 0.4); + background-color: color-mix(in srgb, var(--c-highlight) 40%, transparent); pointer-events: none; border-radius: 2px; } @@ -328,7 +322,7 @@ width: 0.75rem; height: 0.75rem; border-radius: 999px; - border: 1px solid rgba(15, 23, 42, 0.15); + border: 1px solid var(--c-border); } .compare-summary__stats { @@ -343,10 +337,10 @@ } .compare-summary__segment { - border: 1px solid var(--mantine-color-gray-3); + border: 1px solid var(--c-border); border-radius: 0.5rem; padding: 0.75rem; - background-color: var(--mantine-color-gray-0); + background-color: var(--c-surface); } .compare-diff-page { @@ -357,10 +351,10 @@ .compare-diff-page__canvas { position: relative; - border: 1px solid var(--border-strong); + border: 1px solid var(--c-border-strong); border-radius: 0.75rem; overflow: hidden; - background-color: var(--bg-surface); + background-color: var(--c-surface); width: 100%; } @@ -381,7 +375,7 @@ margin-right: auto; max-width: 100%; background-color: #fff; /* ensure stable white backing during load */ - border: 1px solid var(--border-subtle); + border: 1px solid var(--c-border-subtle); will-change: transform; } @@ -403,7 +397,7 @@ display: inline-block; padding: 2px 8px; border-radius: 8px; - background-color: var(--compare-page-label-bg); + background-color: var(--c-surface-sunken); color: var(--compare-page-label-fg); } @@ -432,7 +426,7 @@ } .compare-dropdown-option__page { font-size: 0.7rem; - color: var(--text-muted); + color: var(--c-text-subtle); } .compare-dropdown-option__text { display: -webkit-box; @@ -446,11 +440,11 @@ /* Non-sticky in-flow group headers; sticky handled by floating header */ .compare-dropdown-group { position: static; - background: var(--compare-page-label-bg); + background: var(--c-surface-sunken); color: var(--compare-page-label-fg); font-size: 0.75rem; padding: 0.25rem 0.5rem; - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--c-border-subtle); } .compare-dropdown-group.compare-dropdown-group--hidden { @@ -461,15 +455,9 @@ overflow: hidden; } -[data-mantine-color-scheme="dark"] .compare-dropdown-group { - background: var(--compare-page-label-bg); - color: var(--compare-page-label-fg); - border-bottom: 1px solid var(--border-default); -} - /* Light grey rendering flag next to page labels in the dropdown */ .compare-dropdown-rendering-flag { - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: 0.25rem; } @@ -489,7 +477,7 @@ position: sticky; top: 0; z-index: 2; - background: var(--bg-background); + background: var(--c-bg); padding: 0.25rem 0; } @@ -550,7 +538,7 @@ padding-bottom: 32px; } .compare-pixel-page { - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); border-radius: 8px; padding: 12px; background: var(--mantine-color-body, #fff); @@ -568,13 +556,13 @@ } .compare-pixel-triptych figure img { display: block; - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); background: #fff; } .compare-pixel-triptych figcaption { font-size: 11px; text-align: center; - color: var(--mantine-color-dimmed, #868e96); + color: var(--mantine-color-dimmed, var(--c-text-muted)); } .compare-pixel-overlay { position: relative; @@ -583,7 +571,7 @@ .compare-pixel-overlay-img { display: block; width: 100%; - border: 1px solid var(--mantine-color-default-border, #dee2e6); + border: 1px solid var(--mantine-color-default-border, var(--c-border)); } .compare-pixel-overlay-top { position: absolute; diff --git a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx index 7ffd30fa85..8ab2f10662 100644 --- a/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx +++ b/frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.tsx @@ -104,7 +104,7 @@ const GroupedFormatDropdown = ({ cursor: disabled ? "not-allowed" : "pointer", width: "100%", color: disabled - ? "var(--dropdown-trigger-text-disabled)" + ? "var(--c-text-subtle)" : "var(--dropdown-trigger-text)", }} > diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx index 9b6152cf55..c1711a635c 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.tsx @@ -219,8 +219,9 @@ export default function BookmarkEditor({ withBorder p="md" style={{ - borderColor: "var(--border-default)", - background: level === 0 ? "var(--bg-surface)" : "var(--bg-muted)", + borderColor: "var(--c-border)", + background: + level === 0 ? "var(--c-surface)" : "var(--c-surface-sunken)", }} > @@ -380,7 +381,7 @@ export default function BookmarkEditor({ {bookmark.children.map((child) => ( diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index 148917dba6..d5f8901673 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -100,7 +100,7 @@ const EditTableOfContentsWorkbenchView = ({ width: "100%", height: "100%", overflowY: "auto", - background: "var(--bg-raised)", + background: "var(--c-surface-raised)", }} > @@ -121,8 +121,8 @@ const EditTableOfContentsWorkbenchView = ({ radius="md" p="xl" style={{ - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", boxShadow: "var(--shadow-md)", }} > @@ -167,8 +167,8 @@ const EditTableOfContentsWorkbenchView = ({ radius="md" p="xl" style={{ - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", boxShadow: "var(--shadow-md)", }} > diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx index 0c11cf592a..80eb184a42 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx +++ b/frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx @@ -125,8 +125,8 @@ const PerPageSection: React.FC = ({
= ({ block style={{ whiteSpace: "pre-wrap", - backgroundColor: "var(--bg-raised)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface-raised)", + color: "var(--c-text)", maxHeight, overflowY: "auto", }} diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts b/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts index 73e45eee63..b10d60174f 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts +++ b/frontend/editor/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts @@ -5,7 +5,7 @@ type AccordionStyles = Partial>; export const pdfInfoAccordionStyles: AccordionStyles = { item: { - backgroundColor: "var(--accordion-item-bg)", + backgroundColor: "var(--c-surface-raised)", }, control: { backgroundColor: "transparent", diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css index 6f7ae437b1..02e91270fa 100644 --- a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.module.css @@ -5,9 +5,9 @@ flex-direction: row; align-items: center; /* Center align items vertically */ height: 32px; - border: 1px solid var(--border-default); - background-color: var(--mantine-color-white); /* Use Mantine color variable */ - color: var(--text-secondary); + border: 1px solid var(--c-border); + background-color: var(--c-input-bg); + color: var(--c-text-muted); border-radius: var(--radius-sm); padding: 4px 8px; font-size: 13px; @@ -15,25 +15,9 @@ transition: all 0.2s ease; } -/* Dark mode background */ -[data-mantine-color-scheme="dark"] .languagePicker { - background-color: var( - --mantine-color-dark-6 - ); /* Use Mantine dark color instead of hardcoded */ -} - .languagePicker:hover { - border-color: var(--border-strong); - background-color: var( - --mantine-color-gray-0 - ); /* Light gray on hover for light mode */ -} - -/* Dark mode hover */ -[data-mantine-color-scheme="dark"] .languagePicker:hover { - background-color: var( - --mantine-color-dark-5 - ); /* Use Mantine color variable */ + border-color: var(--c-border-strong); + background-color: var(--c-hover); } .languagePicker:disabled { @@ -43,30 +27,25 @@ .languagePickerIcon { font-size: 16px; - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: auto; display: flex; align-items: center; /* Center the icon vertically */ } .languagePickerDropdown { - background-color: var(--mantine-color-white); /* Use Mantine color variable */ - border: 1px solid var(--border-default); + background-color: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-sm); padding: 4px; } -/* Dark mode dropdown background */ -[data-mantine-color-scheme="dark"] .languagePickerDropdown { - background-color: var(--mantine-color-dark-6); -} - .languagePickerOption { padding: 6px 10px; cursor: pointer; border-radius: var(--radius-xs); font-size: 13px; - color: var(--text-primary); + color: var(--c-text); transition: background-color 0.2s ease; } @@ -81,14 +60,7 @@ } .languagePickerOption:hover { - background-color: var( - --mantine-color-gray-0 - ); /* Light gray on hover for light mode */ -} - -/* Dark mode option hover */ -[data-mantine-color-scheme="dark"] .languagePickerOption:hover { - background-color: var(--mantine-color-dark-5); + background-color: var(--c-hover); } /* Additional helper classes for the component */ @@ -110,7 +82,7 @@ .languagePickerScrollArea { max-height: 180px; - border-bottom: 1px solid var(--border-default); + border-bottom: 1px solid var(--c-border); padding-bottom: 8px; } @@ -121,12 +93,7 @@ } .languagePickerLink { - color: var(--mantine-color-blue-6); + color: var(--c-accent-fg); text-decoration: underline; cursor: pointer; } - -/* Dark mode link */ -[data-mantine-color-scheme="dark"] .languagePickerLink { - color: var(--mantine-color-blue-4); -} diff --git a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx index 4649df64b4..78d51bcc3b 100644 --- a/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx +++ b/frontend/editor/src/core/components/tools/ocr/LanguagePicker.tsx @@ -158,7 +158,7 @@ const LanguagePicker: React.FC = ({
Manual redaction interface will be available here when implemented. diff --git a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx index b01b717bb0..a5bbe2fa66 100644 --- a/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx +++ b/frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.tsx @@ -40,7 +40,7 @@ export default function ReorganizePagesSettings({ {selectedMode && (
); diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css index 301589a930..7237fe7a3b 100644 --- a/frontend/editor/src/core/components/tools/showJS/ShowJSView.css +++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.css @@ -7,7 +7,7 @@ white-space: pre; tab-size: 2; margin: 0; - color: var(--text-primary); + color: var(--c-text); } .tok-kw { @@ -35,7 +35,7 @@ align-items: center; gap: 6px; min-width: 64px; - color: var(--text-muted); + color: var(--c-text-subtle); user-select: none; } .line-number { @@ -45,7 +45,7 @@ .fold-toggle { border: none; background: transparent; - color: var(--text-muted); + color: var(--c-text-subtle); cursor: pointer; padding: 0 2px; } @@ -62,21 +62,29 @@ flex: 1 1 auto; } .collapsed-indicator { - color: var(--text-muted); + color: var(--c-text-subtle); font-style: italic; cursor: pointer; padding-left: 8px; } .collapsed-inline { - color: var(--text-muted); + color: var(--c-text-subtle); margin-left: 6px; } .search-hit { - background: rgba(255, 235, 59, 0.4); /* yellow highlight */ + background: color-mix( + in srgb, + var(--c-highlight) 40%, + transparent + ); /* yellow highlight */ border-radius: 2px; } .search-hit-active { - background: rgba(33, 150, 243, 0.4); /* active blue */ + background: color-mix( + in srgb, + var(--c-primary) 40%, + transparent + ); /* active blue */ } .showjs-root { @@ -92,7 +100,7 @@ border: 1px solid var(--mantine-color-gray-4); border-radius: 8px; overflow: hidden; - background: var(--bg-file-manager); + background: var(--c-bg); } .showjs-toolbar { diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css index b48932d6aa..b3b8fe3a2b 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css @@ -12,12 +12,12 @@ } .tool-picker-scrollable::-webkit-scrollbar-thumb { - background-color: var(--mantine-color-gray-4); + background-color: var(--c-border); border-radius: 0.1875rem; } .tool-picker-scrollable::-webkit-scrollbar-thumb:hover { - background-color: var(--mantine-color-gray-5); + background-color: var(--c-border-strong); } .search-input { @@ -28,7 +28,7 @@ text-transform: uppercase; padding-bottom: 0.5rem; font-size: 0.75rem; - color: var(--tool-subcategory-text-color); + color: var(--c-text-subtle); /* Align the text with tool labels to account for icon gutter */ padding-left: 1rem; } @@ -44,24 +44,20 @@ text-transform: uppercase; font-weight: 600; font-size: 0.75rem; - color: var(--tool-subcategory-text-color); + color: var(--c-text-subtle); white-space: nowrap; overflow: visible; } .tool-subcategory-row-rule { height: 1px; - background-color: var(--tool-subcategory-rule-color); + background-color: var(--c-border-subtle); flex: 1 1 auto; } -/* Selected tool highlight — theme-aware via CSS variable */ +/* Selected tool highlight — theme-aware via the adaptive --c-active token */ :root { - --tool-button-selected-bg: var(--mantine-color-gray-2); -} - -[data-mantine-color-scheme="dark"] { - --tool-button-selected-bg: var(--mantine-color-dark-4); + --tool-button-selected-bg: var(--c-active); } /* Compact tool buttons (padding via the Button `p`/`py` props). diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx index 66463b9005..39eaf2a57b 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx @@ -148,15 +148,13 @@ const ToolSearch = ({ setDropdownOpen(false); }} leftSection={ -
- {tool.icon} -
+
{tool.icon}
} fullWidth justify="start" style={{ borderRadius: "6px", - color: "var(--tools-text-and-icon-color)", + color: "var(--c-text)", padding: "8px 12px", }} > diff --git a/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css b/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css index 096470305e..b4c9e1c815 100644 --- a/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css +++ b/frontend/editor/src/core/components/tools/validateSignature/reportView/styles.css @@ -44,7 +44,7 @@ .simulated-page { width: min(820px, 100%); min-height: 1040px; - background-color: var(--bg-raised) !important; + background-color: var(--c-surface-raised) !important; box-shadow: 0 12px 32px var(--shadow-color) !important; border-radius: 12px !important; padding: 48px 56px !important; @@ -52,7 +52,7 @@ overflow: hidden; display: flex; flex-direction: column; - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Container for the interactive report view */ @@ -60,19 +60,19 @@ width: 100%; height: 100%; /* Match Active Files/Page Editor background */ - background: var(--bg-background) !important; + background: var(--c-bg) !important; padding: 32px 24px 48px; overflow-y: auto; } /* Keep field blocks stable colors across themes */ .field-value { - border: 1px solid var(--border-default) !important; - background-color: var(--bg-raised) !important; + border: 1px solid var(--c-border) !important; + background-color: var(--c-surface-raised) !important; } .field-container { - color: var(--text-primary) !important; + color: var(--c-text) !important; } /* Thumbnail preview styles */ @@ -111,19 +111,19 @@ /* Flash highlight animation for section navigation */ @keyframes section-flash { 0% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--c-highlight) 0%, transparent); box-shadow: none; } 20% { - background-color: rgba(255, 235, 59, 0.35); - box-shadow: 0 0 20px rgba(255, 235, 59, 0.5); + background-color: color-mix(in srgb, var(--c-highlight) 35%, transparent); + box-shadow: 0 0 20px color-mix(in srgb, var(--c-highlight) 50%, transparent); } 50% { - background-color: rgba(255, 235, 59, 0.25); - box-shadow: 0 0 15px rgba(255, 235, 59, 0.4); + background-color: color-mix(in srgb, var(--c-highlight) 25%, transparent); + box-shadow: 0 0 15px color-mix(in srgb, var(--c-highlight) 40%, transparent); } 100% { - background-color: rgba(255, 235, 59, 0); + background-color: color-mix(in srgb, var(--c-highlight) 0%, transparent); box-shadow: none; } } diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css index 6c20964ab3..addaca8979 100644 --- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.css +++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.css @@ -21,7 +21,7 @@ border-radius: 0.65rem; cursor: pointer; transition: all 0.2s ease; - background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 86%, transparent); border: 1px solid transparent; width: 100%; box-sizing: border-box; @@ -31,12 +31,8 @@ } .attachment-item:hover { - background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); - border-color: color-mix( - in srgb, - var(--text-primary) 20%, - var(--border-subtle) - ); + background: color-mix(in srgb, var(--c-text) 8%, var(--c-bg-raised)); + border-color: color-mix(in srgb, var(--c-text) 20%, var(--c-border-subtle)); transform: translateX(2px); } @@ -46,7 +42,7 @@ .attachment-item:focus-visible { outline: 2px solid - color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + color-mix(in srgb, var(--c-text) 30%, var(--c-border-subtle)); outline-offset: 2px; } @@ -54,7 +50,7 @@ .attachment-item__download-icon { flex-shrink: 0; transition: transform 0.2s ease; - color: var(--text-muted); + color: var(--c-text-subtle); } .attachment-item:hover .attachment-item__download-icon { @@ -75,7 +71,7 @@ line-height: 1.35; overflow-wrap: anywhere; word-break: break-word; - color: var(--text-primary); + color: var(--c-text); } .attachment-item__meta { diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.css b/frontend/editor/src/core/components/viewer/BookmarkSidebar.css index 74214fe8b9..5d8beab4e0 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.css +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.css @@ -32,16 +32,12 @@ .bookmark-item--clickable { cursor: pointer; - background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); + background: color-mix(in srgb, var(--c-bg-raised) 86%, transparent); } .bookmark-item--clickable:hover { - background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); - border-color: color-mix( - in srgb, - var(--text-primary) 20%, - var(--border-subtle) - ); + background: color-mix(in srgb, var(--c-text) 8%, var(--c-bg-raised)); + border-color: color-mix(in srgb, var(--c-text) 20%, var(--c-border-subtle)); transform: translateX(2px); } @@ -51,7 +47,7 @@ .bookmark-item--clickable:focus-visible { outline: 2px solid - color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); + color-mix(in srgb, var(--c-text) 30%, var(--c-border-subtle)); outline-offset: 2px; } @@ -75,7 +71,7 @@ width: 2rem; height: 2rem; flex-shrink: 0; - color: var(--text-muted); + color: var(--c-text-subtle); font-size: 1.25rem; opacity: 0.5; font-weight: 300; @@ -94,11 +90,11 @@ line-height: 1.35; overflow-wrap: anywhere; word-break: break-word; - color: var(--text-primary); + color: var(--c-text); } .bookmark-item--clickable:hover .bookmark-item__title { - color: var(--text-primary); + color: var(--c-text); } .bookmark-item__page { diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index a9125215bc..2acd3e309d 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -845,9 +845,10 @@ export const BookmarkSidebar = ({ p="sm" data-testid="bookmark-add-form" style={{ - border: "1px solid var(--border-subtle)", + border: "1px solid var(--c-border-subtle)", borderRadius: 6, - background: "var(--bg-raised, var(--mantine-color-gray-0))", + background: + "var(--c-surface-raised, var(--mantine-color-gray-0))", }} > @@ -945,8 +946,8 @@ export const BookmarkSidebar = ({ px="sm" py="xs" style={{ - borderTop: "1px solid var(--border-subtle)", - backgroundColor: "var(--bg-toolbar)", + borderTop: "1px solid var(--c-border-subtle)", + backgroundColor: "var(--c-bg-raised)", flexShrink: 0, }} > diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index e25f9671d9..3bbc5c8f53 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -679,8 +679,8 @@ export function CommentsSidebar({ top: 0, bottom: 0, width: SIDEBAR_WIDTH, - backgroundColor: "var(--bg-file-manager)", - borderLeft: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-bg)", + borderLeft: "1px solid var(--c-border-subtle)", zIndex: 998, display: "flex", flexDirection: "column", @@ -690,7 +690,7 @@ export function CommentsSidebar({
@@ -902,9 +902,9 @@ export function CommentsSidebar({ style={{ border: selectedAnnotationIds.has(id) ? "1px solid var(--mantine-color-blue-3)" - : "1px solid var(--border-subtle)", + : "1px solid var(--c-border-subtle)", borderRadius: 8, - backgroundColor: "var(--bg-raised)", + backgroundColor: "var(--c-surface-raised)", }} > {displayZoomPercent}% diff --git a/frontend/editor/src/core/components/viewer/SidebarBase.css b/frontend/editor/src/core/components/viewer/SidebarBase.css index 058796038d..8e7771dead 100644 --- a/frontend/editor/src/core/components/viewer/SidebarBase.css +++ b/frontend/editor/src/core/components/viewer/SidebarBase.css @@ -6,15 +6,15 @@ flex-direction: column; background: linear-gradient( 135deg, - color-mix(in srgb, var(--bg-toolbar) 96%, transparent), - color-mix(in srgb, var(--bg-background) 90%, transparent) + color-mix(in srgb, var(--c-bg-raised) 96%, transparent), + color-mix(in srgb, var(--c-bg) 90%, transparent) ); border-left: 1px solid - color-mix(in srgb, var(--border-subtle) 75%, transparent); + color-mix(in srgb, var(--c-border-subtle) 75%, transparent); box-shadow: -2px 0 16px color-mix( in srgb, - var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, + var(--shadow-color, color-mix(in srgb, black 35%, transparent)) 20%, transparent ); backdrop-filter: blur(12px); @@ -26,8 +26,8 @@ align-items: center; justify-content: space-between; padding: 0.75rem 0.875rem; - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border-subtle); + background: var(--c-bg-raised); + border-bottom: 1px solid var(--c-border-subtle); } .sidebar-base__header-title { @@ -46,9 +46,8 @@ /* Search Section (used by bookmark & attachment sidebars) */ .sidebar-base__search { - background: var(--tool-panel-search-bg, var(--bg-toolbar)); - border-bottom: 1px solid - var(--tool-panel-search-border-bottom, var(--border-subtle)); + background: var(--c-surface-sunken, var(--c-bg-raised)); + border-bottom: 1px solid var(--c-border, var(--c-border-subtle)); padding-top: 0.75rem !important; } diff --git a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx index 6b195c4485..c44271258a 100644 --- a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx @@ -238,7 +238,7 @@ export function ThumbnailSidebar({ onMouseEnter={(e) => { if (scrollState.currentPage !== pageIndex + 1) { e.currentTarget.style.backgroundColor = - "var(--hover-bg)"; + "var(--c-hover)"; } }} onMouseLeave={(e) => { @@ -259,7 +259,7 @@ export function ThumbnailSidebar({ height: "auto", borderRadius: "4px", boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)", - border: "1px solid var(--border-subtle)", + border: "1px solid var(--c-border-subtle)", }} /> @@ -285,13 +285,13 @@ export function ThumbnailSidebar({ style={{ width: "11.5rem", height: "15rem", - backgroundColor: "var(--bg-muted)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface-sunken)", + border: "1px solid var(--c-border-subtle)", borderRadius: "4px", display: "flex", alignItems: "center", justifyContent: "center", - color: "var(--text-muted)", + color: "var(--c-text-subtle)", fontSize: "12px", }} > @@ -307,7 +307,7 @@ export function ThumbnailSidebar({ color: scrollState.currentPage === pageIndex + 1 ? "var(--color-primary-500)" - : "var(--text-muted)", + : "var(--c-text-subtle)", }} > Page {pageIndex + 1} diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 58b61c71be..120f955b42 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -1199,7 +1199,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devApi.title", "API"), @@ -1219,7 +1219,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devFolderScanning.title", "Automated Folder Scanning"), @@ -1242,7 +1242,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devSsoGuide.title", "SSO Guide"), @@ -1262,7 +1262,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { icon="open-in-new-rounded" width="1.5rem" height="1.5rem" - style={{ color: "#2F7BF6" }} + style={{ color: "var(--c-accent-fg, var(--c-primary))" }} /> ), name: t("home.devAirgapped.title", "Air-gapped Setup"), diff --git a/frontend/editor/src/core/pages/HomePage.css b/frontend/editor/src/core/pages/HomePage.css index cfd3bded5c..7af1e89404 100644 --- a/frontend/editor/src/core/pages/HomePage.css +++ b/frontend/editor/src/core/pages/HomePage.css @@ -3,13 +3,13 @@ flex-direction: column; height: 100%; width: 100%; - background-color: var(--bg-background); + background-color: var(--c-bg); } .mobile-toggle { padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-bottom: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); display: flex; flex-direction: column; gap: 0.35rem; @@ -47,8 +47,8 @@ gap: 0.25rem; padding: 0.2rem; border-radius: 9999px; - background: var(--bg-background); - border: 1px solid var(--border-subtle); + background: var(--c-bg); + border: 1px solid var(--c-border-subtle); } .mobile-toggle-button { @@ -57,7 +57,7 @@ padding: 0.4rem 0.9rem; font-size: 0.8125rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; transition: background 0.2s ease, @@ -65,18 +65,18 @@ } .mobile-toggle-button:focus-visible { - outline: 2px solid var(--primary-color, #228be6); + outline: 2px solid var(--c-primary); outline-offset: 2px; } .mobile-toggle-button.active { - background: var(--primary-surface, rgba(34, 139, 230, 0.12)); - color: var(--text-primary); + background: var(--c-primary-subtle); + color: var(--c-text); } .mobile-toggle-hint { font-size: 0.7rem; - color: var(--text-muted); + color: var(--c-text-subtle); text-align: center; } @@ -134,8 +134,8 @@ align-items: center; justify-content: space-around; padding: 0.5rem; - border-top: 1px solid var(--border-subtle); - background: var(--bg-toolbar); + border-top: 1px solid var(--c-border-subtle); + background: var(--c-bg-raised); gap: 0.5rem; position: relative; z-index: 10; @@ -152,7 +152,7 @@ padding: 0.5rem; border: none; background: transparent; - color: var(--text-primary); + color: var(--c-text); cursor: pointer; border-radius: 0.5rem; transition: background 0.2s ease; @@ -165,16 +165,16 @@ @media (hover: hover) and (pointer: fine) { .mobile-bottom-button:hover { - background: var(--bg-hover, rgba(0, 0, 0, 0.05)); + background: var(--c-hover); } } .mobile-bottom-button:active { - background: var(--bg-active, rgba(0, 0, 0, 0.1)); + background: var(--c-active); } .mobile-bottom-button-label { font-size: 0.75rem; font-weight: 500; - color: var(--text-muted); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index cf4e3e3564..bce84cf371 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -995,7 +995,7 @@ export default function MobileScannerPage() { @@ -1025,7 +1025,7 @@ export default function MobileScannerPage() { background: loadingStatus.includes("✗") ? "var(--mantine-color-red-1)" : "var(--mantine-color-blue-1)", - borderBottom: "1px solid var(--border-subtle)", + borderBottom: "1px solid var(--c-border-subtle)", fontSize: "0.85rem", fontFamily: "monospace", textAlign: "center", @@ -1231,8 +1231,8 @@ export default function MobileScannerPage() { {/* Controls bar - fixed at bottom */} @@ -1372,8 +1372,8 @@ export default function MobileScannerPage() { {/* Controls bar - fixed at bottom */} @@ -1401,7 +1401,7 @@ export default function MobileScannerPage() { )} {capturedImages.length > 0 && ( - + {t("mobileScanner.batchImages", "Batch")} ({capturedImages.length} @@ -1442,7 +1442,7 @@ export default function MobileScannerPage() { height: "80px", borderRadius: "var(--radius-sm)", overflow: "hidden", - border: "2px solid var(--border-subtle)", + border: "2px solid var(--c-border-subtle)", }} > ({ root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border)", + color: "var(--c-text)", "&:hover": { - backgroundColor: "var(--hover-bg)", + backgroundColor: "var(--c-hover)", borderColor: "var(--color-primary-500)", }, }, @@ -150,8 +150,8 @@ export const mantineTheme = createTheme({ Paper: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", }, }, }, @@ -159,8 +159,8 @@ export const mantineTheme = createTheme({ Card: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-sm)", }, }, @@ -170,7 +170,7 @@ export const mantineTheme = createTheme({ styles: { root: { backgroundColor: "var(--color-gray-100)", - color: "var(--text-primary)", + color: "var(--c-text)", }, }, }, @@ -178,16 +178,16 @@ export const mantineTheme = createTheme({ Textarea: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -196,16 +196,16 @@ export const mantineTheme = createTheme({ TextInput: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -214,16 +214,16 @@ export const mantineTheme = createTheme({ PasswordInput: { styles: (_theme: MantineTheme) => ({ input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, }), @@ -232,26 +232,26 @@ export const mantineTheme = createTheme({ Select: { styles: { input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, dropdown: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, option: { - color: "var(--text-primary)", - "--combobox-option-hover": "var(--hover-bg)", + color: "var(--c-text)", + "--combobox-option-hover": "var(--c-hover)", "--combobox-option-selected": "var(--color-primary-100)", }, }, @@ -260,26 +260,26 @@ export const mantineTheme = createTheme({ MultiSelect: { styles: { input: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-default)", - color: "var(--text-primary)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border)", + color: "var(--c-text)", "&:focus": { borderColor: "var(--color-primary-500)", boxShadow: "0 0 0 1px var(--color-primary-500)", }, }, label: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", fontWeight: "var(--font-weight-medium)", }, dropdown: { - backgroundColor: "var(--bg-surface)", - borderColor: "var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderColor: "var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, option: { - color: "var(--text-primary)", - "--combobox-option-hover": "var(--hover-bg)", + color: "var(--c-text)", + "--combobox-option-hover": "var(--c-hover)", "--combobox-option-selected": "var(--color-primary-100)", }, }, @@ -287,8 +287,9 @@ export const mantineTheme = createTheme({ Tooltip: { styles: { tooltip: { - backgroundColor: "var( --tooltip-title-bg)", - color: "var( --tooltip-title-color)", + backgroundColor: + "color-mix( in srgb, var(--c-primary) 12%, var(--c-surface) )", + color: "var(--c-text)", border: "1px solid var(--tooltip-border)", fontSize: "0.75rem", fontWeight: "500", @@ -301,14 +302,14 @@ export const mantineTheme = createTheme({ Checkbox: { styles: { input: { - borderColor: "var(--border-default)", + borderColor: "var(--c-border)", "&:checked": { backgroundColor: "var(--color-primary-500)", borderColor: "var(--color-primary-500)", }, }, label: { - color: "var(--text-primary)", + color: "var(--c-text)", }, }, }, @@ -316,7 +317,7 @@ export const mantineTheme = createTheme({ Slider: { styles: { track: { - backgroundColor: "var(--bg-muted)", + backgroundColor: "var(--c-surface-sunken)", }, bar: { backgroundColor: "var(--color-primary-500)", @@ -326,10 +327,10 @@ export const mantineTheme = createTheme({ borderColor: "var(--color-primary-500)", }, mark: { - borderColor: "var(--border-default)", + borderColor: "var(--c-border)", }, markLabel: { - color: "var(--text-muted)", + color: "var(--c-text-subtle)", }, }, }, @@ -337,16 +338,16 @@ export const mantineTheme = createTheme({ Modal: { styles: { content: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-xl)", }, header: { - backgroundColor: "var(--bg-surface)", - borderBottom: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + borderBottom: "1px solid var(--c-border-subtle)", }, title: { - color: "var(--text-primary)", + color: "var(--c-text)", fontWeight: "var(--font-weight-semibold)", }, }, @@ -355,15 +356,15 @@ export const mantineTheme = createTheme({ Notification: { styles: { root: { - backgroundColor: "var(--bg-surface)", - border: "1px solid var(--border-subtle)", + backgroundColor: "var(--c-surface)", + border: "1px solid var(--c-border-subtle)", boxShadow: "var(--shadow-lg)", }, title: { - color: "var(--text-primary)", + color: "var(--c-text)", }, description: { - color: "var(--text-secondary)", + color: "var(--c-text-muted)", }, }, }, diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 901a3dd646..22716816a2 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -42,4 +42,128 @@ --p-red-400: #f87171; --p-red-500: #ef4444; --p-red-600: #dc2626; + + /* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */ + --p-brand-red-200: #d9a8a8; + --p-brand-red-300: #d98a8a; + --p-brand-red-650: #8e3131; + --p-brand-red-700: #7a2929; + --p-brand-red-900: #5a2424; + --p-cyan-400: #22d3ee; + --p-indigo-200: #c7d2fe; + --p-indigo-300: #a5b4fc; + --p-indigo-400: #818cf8; + --p-indigo-500: #6366f1; + --p-indigo-800: #3730a3; + + /* Extended accent/status/neutral weights referenced by app CSS. */ + --p-azure-300: #7ab4ff; + --p-azure-400: #38bdf8; + --p-cyan-500: #06b6d4; + --p-green-400: #4ade80; + --p-pink-500: #ec4899; + --p-purple-500: #6c5ce7; + --p-periwinkle-500: #635bff; + --p-teal-700: #0f7b6c; + --p-gray-450: #868e96; + --p-gray-b0: #b0b0b0; + --p-gray-mid: #808080; + + /* Brand red (login/auth CTAs) — base + hover, complementing the -200..-900 scale above. */ + --p-brand-red: #af3434; + --p-brand-red-600: #9a2e2e; + + /* Highlight flash (compare / show-JS / signature-report animations). */ + --p-flash-yellow: #ffeb3b; + + /* Onboarding hero gradient stops (dark decorative navies + light tints). */ + --p-navy-850: #1a2236; + --p-navy-880: #171e30; + --p-navy-900: #0f1626; + --p-plum-900: #201a28; + --p-tint-blue: #eef1fb; + --p-tint-violet: #f6f4fc; + --p-tint-pink: #fbf4f7; + + /* Notion-style procurement view palette. */ + --p-notion-blue: #2383e2; + --p-notion-blue-strong: #1b6ec2; + --p-notion-blue-border: #b8d5f2; + --p-notion-ink: #37352f; + --p-notion-gray: #9b9a97; + --p-notion-gray-strong: #787774; + --p-notion-paper: #f5f4f1; + --p-notion-paper-2: #f0eee9; + --p-notion-border: #e3e1dc; + --p-notion-border-2: #eae8e3; + --p-notion-border-cool: #d3d1cb; + + /* Vendor status chips (procurement / auth callback). */ + --p-vendor-red: #ef5350; + --p-vendor-red-text: #c62828; + --p-vendor-red-soft: #ef9a9a; + --p-vendor-red-bg: #ffebee; + --p-vendor-red-border: #ffcdd2; + --p-vendor-red-bg-dark: #3d2020; + --p-vendor-red-border-dark: #5d3030; + --p-vendor-green: #66bb6a; + + /* Palette entries migrated from literals in consuming files. */ + --p-c-0550ae: #0550ae; + --p-emerald-600: #059669; + --p-code-string: #0a3069; + --p-azure-500: #0a8bff; + --p-navy-950: #0d1020; + --p-c-0f172a: #0f172a; + --p-emerald-500: #10b981; + --p-navy-700: #16213e; + --p-c-1a2332: #1a2332; + --p-c-1c2340: #1c2340; + --p-c-1e293b: #1e293b; + --p-c-1f2328: #1f2328; + --p-royal-700: #2040a0; + --p-c-2d3560: #2d3560; + --p-emerald-400: #34d399; + --p-azure-650: #3a7be8; + --p-navy-500: #3b4b6e; + --p-c-475569: #475569; + --p-c-334155: #334155; + --p-c-cbd5e1: #cbd5e1; + --p-azure-550: #4c8bf5; + --p-c-545454: #545454; + --p-azure-450: #5b9bf7; + --p-c-64748b: #64748b; + --p-c-656d76: #656d76; + --p-c-6e7781: #6e7781; + --p-violet-600: #7c3aed; + --p-c-7e7e7e: #7e7e7e; + --p-c-8250df: #8250df; + --p-violet-500: #8b5cf6; + --p-c-8c959f: #8c959f; + --p-blue-200: #93c5fd; + --p-c-94a3b8: #94a3b8; + --p-code-type: #953800; + --p-violet-400: #a78bfa; + --p-c-acacac: #acacac; + --p-c-c084fc: #c084fc; + --p-art-red-muted: #c56565; + --p-c-cf222e: #cf222e; + --p-c-d0d6dc: #d0d6dc; + --p-c-d0d7de: #d0d7de; + --p-c-d0dbdc: #d0dbdc; + --p-c-d3d3d3: #d3d3d3; + --p-c-d62626: #d62626; + --p-c-e6e6e6: #e6e6e6; + --p-c-eaeef2: #eaeef2; + --p-c-eef1f4: #eef1f4; + --p-c-f1f5f9: #f1f5f9; + --p-c-f472b6: #f472b6; + --p-c-f6f8fa: #f6f8fa; + --p-c-f8fafc: #f8fafc; + --p-art-red-soft: #fc9999; + --p-art-red-light: #fca5a5; + --p-art-red: #ff4b4b; + --p-c-ffc107: #ffc107; + --p-c-c2c8e0: #c2c8e0; + --p-c-4f8ef5: #4f8ef5; } diff --git a/frontend/editor/src/core/tokens/Tokens.stories.tsx b/frontend/editor/src/core/tokens/Tokens.stories.tsx index 7543d7d4d6..0003c06b17 100644 --- a/frontend/editor/src/core/tokens/Tokens.stories.tsx +++ b/frontend/editor/src/core/tokens/Tokens.stories.tsx @@ -42,8 +42,8 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { alignItems: "center", gap: 10, padding: 10, - background: "var(--color-surface)", - border: "1px solid var(--color-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", borderRadius: "var(--radius-md)", }} > @@ -53,7 +53,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { height: 36, borderRadius: 6, background: `var(${s.varName})`, - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", flexShrink: 0, }} /> @@ -62,7 +62,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { style={{ fontSize: 12, fontWeight: 500, - color: "var(--color-text-2)", + color: "var(--c-text-muted)", }} > {s.label} @@ -71,7 +71,7 @@ function Group({ heading, swatches }: { heading: string; swatches: Swatch[] }) { style={{ fontSize: 11, fontFamily: "var(--font-mono)", - color: "var(--color-text-4)", + color: "var(--c-text-subtle)", overflow: "hidden", textOverflow: "ellipsis", }} @@ -154,14 +154,14 @@ export const Typography: Story = { display: "flex", flexDirection: "column", gap: 18, - color: "var(--color-text-1)", + color: "var(--c-text)", }} >
@@ -181,7 +181,7 @@ export const Typography: Story = {
@@ -193,7 +193,7 @@ export const Typography: Story = {
@@ -207,7 +207,7 @@ export const Typography: Story = {
@@ -221,7 +221,7 @@ export const Typography: Story = {
@@ -240,8 +240,8 @@ export const Motion: Story = {
{ data-slot-state="filled" data-slot-filename={stub?.name} style={{ - border: "1px solid var(--border-default)", + border: "1px solid var(--c-border)", borderRadius: "var(--radius-md)", padding: "0.75rem 1rem", - background: "var(--bg-surface)", + background: "var(--c-surface)", width: "100%", minHeight: "9rem", position: "relative", diff --git a/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx b/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx index 2e49991ceb..d348c5c241 100644 --- a/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFieldSidebar.tsx @@ -82,9 +82,9 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) { zIndex: 999, display: "flex", flexDirection: "column", - background: "var(--bg-toolbar, var(--mantine-color-body))", + background: "var(--c-bg-raised, var(--mantine-color-body))", borderLeft: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", boxShadow: "-4px 0 16px rgba(0,0,0,0.08)", }} > @@ -96,7 +96,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) { justifyContent: "space-between", padding: "0.625rem 0.75rem", borderBottom: - "1px solid var(--border-subtle, var(--mantine-color-default-border))", + "1px solid var(--c-border-subtle, var(--mantine-color-default-border))", flexShrink: 0, }} > diff --git a/frontend/editor/src/core/tools/formFill/FormFill.module.css b/frontend/editor/src/core/tools/formFill/FormFill.module.css index 4cffc284f5..800e610c03 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.module.css +++ b/frontend/editor/src/core/tools/formFill/FormFill.module.css @@ -8,8 +8,7 @@ .modeTabs { flex-shrink: 0; - border-bottom: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border)); background: transparent; padding: 0.25rem; } @@ -44,7 +43,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.02em; - color: var(--text-muted); + color: var(--c-text-subtle); transition: color 0.15s ease; line-height: 1; } @@ -59,8 +58,7 @@ flex-shrink: 0; padding: 0.75rem 1rem; background: transparent; - border-bottom: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-bottom: 1px solid var(--c-border, var(--mantine-color-default-border)); display: flex; flex-direction: column; gap: 0.625rem; @@ -76,7 +74,7 @@ .progressLabel { font-size: 0.6875rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); white-space: nowrap; } @@ -133,7 +131,7 @@ content: ""; flex: 1; height: 1px; - background: var(--border-default, var(--mantine-color-default-border)); + background: var(--c-border, var(--mantine-color-default-border)); opacity: 0.2; } @@ -142,22 +140,22 @@ font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; - color: var(--text-muted); + color: var(--c-text-subtle); opacity: 0.6; } .fieldCard { padding: 0.625rem 0.75rem; border-radius: var(--radius-md); - border: 1px solid var(--border-default, var(--mantine-color-default-border)); - background: var(--bg-surface, var(--mantine-color-body)); + border: 1px solid var(--c-border, var(--mantine-color-default-border)); + background: var(--c-surface, var(--mantine-color-body)); cursor: pointer; transition: all 0.15s ease; } .fieldCard:hover { border-color: var(--mantine-color-blue-5); - background: var(--bg-surface); + background: var(--c-surface); } .fieldCardActive { @@ -193,7 +191,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--text-primary); + color: var(--c-text); } .fieldRequired { @@ -214,7 +212,7 @@ .fieldHint { margin-top: 0.375rem; font-size: 0.6875rem; - color: var(--text-muted); + color: var(--c-text-subtle); line-height: 1.4; font-style: italic; opacity: 0.8; @@ -235,7 +233,7 @@ gap: 0.75rem; padding: 5rem 1.5rem; text-align: center; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; } @@ -263,14 +261,13 @@ .statusBar { flex-shrink: 0; padding: 0.5rem 1rem; - border-top: 1px solid - var(--border-default, var(--mantine-color-default-border)); + border-top: 1px solid var(--c-border, var(--mantine-color-default-border)); display: flex; align-items: center; justify-content: space-between; font-size: 0.6875rem; font-weight: 600; - color: var(--text-muted); + color: var(--c-text-subtle); background: transparent; } @@ -294,7 +291,7 @@ .comingSoonTitle { font-size: 1rem; font-weight: 800; - color: var(--text-primary); + color: var(--c-text); text-transform: uppercase; letter-spacing: 0.05em; } @@ -302,6 +299,6 @@ .comingSoonDesc { font-size: 0.75rem; line-height: 1.6; - color: var(--text-muted); + color: var(--c-text-subtle); max-width: 200px; } diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx index 44584be569..682e4b41e5 100644 --- a/frontend/editor/src/core/ui/ActionIcon.tsx +++ b/frontend/editor/src/core/ui/ActionIcon.tsx @@ -100,7 +100,7 @@ export const ActionIcon = forwardRef( "--ai-bg": "transparent", "--ai-hover": "transparent", "--ai-color": "var(--_text)", - "--ai-hover-color": "var(--color-text-1)", + "--ai-hover-color": "var(--c-text)", "--ai-bd": "1px solid transparent", } : { diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index 21089db8f0..e2e8dc63bd 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -70,6 +70,6 @@ background: var(--grad-red-btn); } .sui-avatar--neutral { - background: var(--color-bg-muted); - color: var(--color-text-2); + background: var(--c-surface-sunken); + color: var(--c-text-muted); } diff --git a/frontend/editor/src/core/ui/Banner.css b/frontend/editor/src/core/ui/Banner.css index 937905a915..6f3a53e930 100644 --- a/frontend/editor/src/core/ui/Banner.css +++ b/frontend/editor/src/core/ui/Banner.css @@ -10,29 +10,29 @@ } .sui-banner--info { - background: var(--color-blue-light); - color: var(--color-text-2); - border-color: var(--color-blue-border); + background: var(--c-primary-tint); + color: var(--c-text-muted); + border-color: var(--c-primary-border); } .sui-banner--success { background: var(--color-green-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-green-border); } .sui-banner--warning { background: var(--color-amber-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-amber-border); } .sui-banner--danger { background: var(--color-red-light); - color: var(--color-text-2); + color: var(--c-text-muted); border-color: var(--color-red-border); } .sui-banner--neutral { background: var(--color-bg-subtle); - color: var(--color-text-2); - border-color: var(--color-border); + color: var(--c-text-muted); + border-color: var(--c-border); } .sui-banner__icon { @@ -45,7 +45,7 @@ } .sui-banner--info .sui-banner__icon { - color: var(--color-blue); + color: var(--c-primary); } .sui-banner--success .sui-banner__icon { color: var(--color-green); @@ -63,10 +63,10 @@ } .sui-banner__title { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-banner__desc { - color: var(--color-text-3); + color: var(--c-text-subtle); margin-top: 0.125rem; } diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index 6ec23ca9ea..529d97fe16 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -67,8 +67,8 @@ [data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]), [data-theme="dark"] .sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) { - background: var(--color-bg-muted); - color: var(--color-text-5); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); border-color: transparent; opacity: 1; } diff --git a/frontend/editor/src/core/ui/Button.stories.tsx b/frontend/editor/src/core/ui/Button.stories.tsx index d35a24aed7..374d31c46e 100644 --- a/frontend/editor/src/core/ui/Button.stories.tsx +++ b/frontend/editor/src/core/ui/Button.stories.tsx @@ -172,7 +172,7 @@ export const Loading: Story = { /** Disabled primary — dark mode keeps a muted accent instead of grey. */ export const DisabledDark: Story = { render: () => ( -
+
setOpen(false)}> -

+

The drawer body scrolls when its content overflows. The header and footer (when present) are sticky.

@@ -79,7 +79,7 @@ export const WithFooter: Story = { } > -

+

Sticky footer demo — scroll the body, footer stays anchored.

diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 736371e6ae..38b20ee1d8 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -8,8 +8,8 @@ top: calc(100% + var(--space-1)); min-width: 12rem; padding: var(--space-1); - background: var(--color-dropdown-bg); - border: 1px solid var(--color-dropdown-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); z-index: var(--z-dropdown); @@ -34,7 +34,7 @@ width: 100%; text-align: left; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); border-radius: var(--radius-sm); /* Explicit button reset: hosts without a global button reset (e.g. the editor) would otherwise show the UA's buttonface background and border. */ @@ -48,13 +48,13 @@ } .sui-dd__item:hover:not(.is-disabled) { - background: var(--color-dropdown-hover); - color: var(--color-text-1); + background: var(--c-hover); + color: var(--c-text); } .sui-dd__item.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); + background: var(--c-primary-subtle); + color: var(--c-accent-fg); font-weight: 500; } @@ -73,11 +73,11 @@ margin-left: auto; font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .sui-dd__divider { height: 1px; - background: var(--color-divider); + background: var(--c-border-subtle); margin: var(--space-1) 0; } diff --git a/frontend/editor/src/core/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css index e78db8b664..bf8250bc82 100644 --- a/frontend/editor/src/core/ui/EmptyState.css +++ b/frontend/editor/src/core/ui/EmptyState.css @@ -14,7 +14,7 @@ .sui-empty__icon { display: inline-flex; margin-bottom: var(--space-2); - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-empty__eyebrow { @@ -22,14 +22,14 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-blue); + color: var(--c-primary); } .sui-empty__title { margin: 0; font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-empty--compact .sui-empty__title { @@ -41,7 +41,7 @@ max-width: 32rem; font-size: 0.875rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } .sui-empty--compact .sui-empty__copy { diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index 0ae531bc65..f5cbe2d5fc 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -24,7 +24,7 @@ .sui-field__help { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.45; } diff --git a/frontend/editor/src/core/ui/IconBadge.css b/frontend/editor/src/core/ui/IconBadge.css index aaca06f217..9aa3f8f46f 100644 --- a/frontend/editor/src/core/ui/IconBadge.css +++ b/frontend/editor/src/core/ui/IconBadge.css @@ -7,7 +7,7 @@ /* Resolved tint. Each accent class sets --ib-base; --ib-accent defaults to it but a consumer can override --ib-accent alone (e.g. to neutralise the badge until hover) without losing the per-accent base. */ - --ib-accent: var(--ib-base, var(--color-blue)); + --ib-accent: var(--ib-base, var(--c-primary)); color: var(--ib-accent); background: color-mix(in srgb, var(--ib-accent) 14%, transparent); } @@ -20,7 +20,7 @@ height: 2rem; } .sui-iconbadge--blue { - --ib-base: var(--color-blue); + --ib-base: var(--c-primary); } .sui-iconbadge--purple { --ib-base: var(--color-purple); @@ -38,6 +38,6 @@ --ib-base: var(--color-orange); } .sui-iconbadge--neutral { - --ib-base: var(--color-text-1); + --ib-base: var(--c-text); background: none; } diff --git a/frontend/editor/src/core/ui/Inline.stories.tsx b/frontend/editor/src/core/ui/Inline.stories.tsx index 10233e1371..5149f36018 100644 --- a/frontend/editor/src/core/ui/Inline.stories.tsx +++ b/frontend/editor/src/core/ui/Inline.stories.tsx @@ -29,7 +29,7 @@ export const SpaceBetween: Story = { style={{ width: "30rem", padding: 12, - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", borderRadius: 8, }} > diff --git a/frontend/editor/src/core/ui/Input.css b/frontend/editor/src/core/ui/Input.css index 09a7ca0cda..3c68bdd8c5 100644 --- a/frontend/editor/src/core/ui/Input.css +++ b/frontend/editor/src/core/ui/Input.css @@ -3,18 +3,18 @@ align-items: center; gap: var(--space-2); padding: 0 var(--space-2); - background: var(--color-surface); - border: 1px solid var(--color-border-input); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - color: var(--color-text-1); + color: var(--c-text); transition: border-color var(--motion-fast), box-shadow var(--motion-fast); } .sui-input:focus-within { - border-color: var(--color-blue); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + border-color: var(--c-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } .sui-input--sm { @@ -61,6 +61,6 @@ .sui-input__icon { display: inline-flex; align-items: center; - color: var(--color-text-4); + color: var(--c-text-subtle); flex-shrink: 0; } diff --git a/frontend/editor/src/core/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css index 8317b76fc5..836dba4d8f 100644 --- a/frontend/editor/src/core/ui/ListRow.css +++ b/frontend/editor/src/core/ui/ListRow.css @@ -6,20 +6,20 @@ padding: 0.7rem 0.875rem; text-align: left; background: transparent; - color: var(--color-text-1); + color: var(--c-text); } .sui-listrow--divider { - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } .sui-listrow--interactive { cursor: pointer; transition: background var(--motion-fast); } .sui-listrow--interactive:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .sui-listrow--interactive:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: -0.125rem; } @@ -32,8 +32,8 @@ border-radius: var(--radius-md); flex-shrink: 0; margin-top: 0.05rem; - color: var(--color-text-4); - background: var(--color-bg-muted); + color: var(--c-text-subtle); + background: var(--c-surface-sunken); } .sui-listrow__leading[data-tone="success"] { color: var(--color-green); @@ -48,8 +48,8 @@ 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); + color: var(--c-primary); + background: color-mix(in srgb, var(--c-primary) 14%, transparent); } .sui-listrow__leading[data-tone="purple"] { color: var(--color-purple); @@ -65,19 +65,19 @@ .sui-listrow__title { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sui-listrow__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-listrow__meta { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-listrow__trailing { diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index fb95e15c07..ebedf547f7 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -13,11 +13,11 @@ * here act as a typed reference and as a fallback for any slot Mantine reads * before the inline vars are applied. */ .sui-mantine-wrapper { - --input-bg: var(--color-surface); - --input-bd: var(--color-border-input); - --input-bd-focus: var(--color-blue); + --input-bg: var(--c-surface); + --input-bd: var(--c-border); + --input-bd-focus: var(--c-primary); --input-radius: var(--radius-md); - --input-color: var(--color-text-1); + --input-color: var(--c-text); --input-placeholder-color: var(--color-text-placeholder); --input-height-sm: 1.75rem; --input-height-md: 2.25rem; @@ -31,7 +31,7 @@ /* SUI focus ring — matches .sui-input:focus-within */ .sui-mantine-wrapper[data-focused], .sui-mantine-wrapper:focus-within { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } /* Error state */ @@ -54,9 +54,9 @@ /* ---- MultiSelect pills ---- */ /* Pills match SUI's Chip component: small rounded tags. */ .sui-mantine-pill { - background: var(--color-blue-light) !important; - color: var(--color-blue-dark) !important; - border: 1px solid var(--color-blue-border) !important; + background: var(--c-primary-tint) !important; + color: var(--c-primary-hover) !important; + border: 1px solid var(--c-primary-border) !important; border-radius: var(--radius-sm) !important; font-size: 0.75rem !important; font-weight: 500 !important; @@ -69,26 +69,26 @@ /* ---- NumberInput controls (increment/decrement buttons) ---- */ .sui-mantine-control { - border-color: var(--color-border-input) !important; - color: var(--color-text-3) !important; + border-color: var(--c-border) !important; + color: var(--c-text-subtle) !important; } .sui-mantine-control:hover { - background: var(--color-bg-hover) !important; - color: var(--color-text-1) !important; + background: var(--c-hover) !important; + color: var(--c-text) !important; } /* ---- Select: hide Mantine's right-section clear button border ---- */ .sui-mantine-wrapper .mantine-Select-section { - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ---- Slider ---- */ .sui-mantine-slider { - --slider-color: var(--color-blue); - --slider-track-bg: var(--color-border); - --slider-thumb-color: var(--color-surface); - --slider-thumb-bd: var(--color-blue); + --slider-color: var(--c-primary); + --slider-track-bg: var(--c-border); + --slider-thumb-color: var(--c-surface); + --slider-thumb-bd: var(--c-primary); } .sui-mantine-slider:focus-within { @@ -97,13 +97,13 @@ /* Thumb focus ring matches SUI */ .sui-mantine-slider .mantine-Slider-thumb:focus-visible { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); outline: none; } /* Mark labels use SUI text tokens */ .sui-mantine-slider .mantine-Slider-markLabel { - color: var(--color-text-3); + color: var(--c-text-subtle); font-size: 0.75rem; } @@ -112,7 +112,7 @@ * SuiProvider syncs forceColorScheme to SUI theme, so * [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */ [data-mantine-color-scheme="dark"] .sui-mantine-wrapper { - --input-bg: var(--color-surface); - --input-bd: var(--color-border-input); - --input-color: var(--color-text-1); + --input-bg: var(--c-surface); + --input-bd: var(--c-border); + --input-color: var(--c-text); } diff --git a/frontend/editor/src/core/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css index 75703b92d8..9b138ed389 100644 --- a/frontend/editor/src/core/ui/MethodBadge.css +++ b/frontend/editor/src/core/ui/MethodBadge.css @@ -10,14 +10,14 @@ line-height: 1.4; } .sui-method--get { - color: var(--color-green); + color: var(--color-green-dark); background: var(--color-green-light); border-color: var(--color-green-border); } .sui-method--post { - color: var(--color-blue); - background: var(--color-blue-light); - border-color: var(--color-blue-border); + color: var(--c-primary); + background: var(--c-primary-tint); + border-color: var(--c-primary-border); } .sui-method--put { color: var(--color-amber-dark); @@ -25,12 +25,12 @@ border-color: var(--color-amber-border); } .sui-method--patch { - color: var(--color-purple); + color: var(--color-purple-dark); background: var(--color-purple-light); border-color: var(--color-purple-border); } .sui-method--delete { - color: var(--color-red); + color: var(--color-red-dark); background: var(--color-red-light); border-color: var(--color-red-border); } diff --git a/frontend/editor/src/core/ui/MethodBadge.stories.tsx b/frontend/editor/src/core/ui/MethodBadge.stories.tsx index 9110ef9f06..6dd557ab82 100644 --- a/frontend/editor/src/core/ui/MethodBadge.stories.tsx +++ b/frontend/editor/src/core/ui/MethodBadge.stories.tsx @@ -23,7 +23,7 @@ export const InRow: Story = {
/v1/coi diff --git a/frontend/editor/src/core/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css index 59c2b45000..8c7adfeb00 100644 --- a/frontend/editor/src/core/ui/MetricCard.css +++ b/frontend/editor/src/core/ui/MetricCard.css @@ -3,8 +3,8 @@ flex-direction: column; gap: 0.5rem; padding: 1.125rem 1.25rem; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); transition: @@ -36,12 +36,12 @@ cursor: pointer; } .sui-metric--interactive:hover { - border-color: var(--color-border-hover); + border-color: var(--c-border-strong); box-shadow: var(--shadow-lg); transform: translateY(-0.0625rem); } .sui-metric--interactive:focus-visible { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } @@ -53,18 +53,18 @@ } .sui-metric__label { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); font-weight: 500; letter-spacing: 0.01em; } .sui-metric__icon { - color: var(--color-text-5); + color: var(--c-text-subtle); display: inline-flex; } .sui-metric__value { font-size: 1.625rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); line-height: 1.1; } .sui-metric__footer { @@ -88,9 +88,7 @@ .sui-metric__delta--down { color: var(--color-red); } -.sui-metric__delta--flat { - color: var(--color-text-5); -} +.sui-metric__delta--flat, .sui-metric__desc { - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index a3d081b42d..2773585415 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,7 +1,7 @@ .sui-modal__backdrop { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.55); + background: rgba(0, 0, 0, 0.55); display: flex; align-items: flex-start; justify-content: center; @@ -15,12 +15,12 @@ /* Set our own font: the modal portals to , so it can't inherit the app's font through the DOM (the portal's .portal-scope isn't an ancestor). */ font-family: var(--font-sans); - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-xl); box-shadow: - 0 1.25rem 3rem rgba(15, 23, 42, 0.35), - 0 0 0 1px var(--color-border-light); + 0 1.25rem 3rem rgba(0, 0, 0, 0.35), + 0 0 0 1px var(--c-border-subtle); display: flex; flex-direction: column; width: 100%; @@ -59,7 +59,7 @@ align-items: flex-start; gap: 0.75rem; padding: 1rem 1.125rem 0.75rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .sui-modal__header-text { @@ -70,13 +70,13 @@ .sui-modal__title { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-modal__sub { margin-top: 0.125rem; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Sizing/background/hover are owned by the Button; this just keeps it from @@ -89,12 +89,12 @@ flex: 1 1 auto; overflow-y: auto; padding: 1rem 1.125rem 1.125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-modal__footer { padding: 0.875rem 1.125rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); display: flex; justify-content: flex-end; gap: 0.5rem; diff --git a/frontend/editor/src/core/ui/MultiSelect.tsx b/frontend/editor/src/core/ui/MultiSelect.tsx index 31ac9544f8..0d2ab92ad8 100644 --- a/frontend/editor/src/core/ui/MultiSelect.tsx +++ b/frontend/editor/src/core/ui/MultiSelect.tsx @@ -8,11 +8,11 @@ import { useInputAria } from "@app/ui/ariaForwarding"; import "@app/ui/MantineForms.css"; const SUI_INPUT_VARS = { - "--input-bg": "var(--color-surface)", - "--input-bd": "var(--color-border-input)", - "--input-bd-focus": "var(--color-blue)", + "--input-bg": "var(--c-surface)", + "--input-bd": "var(--c-border)", + "--input-bd-focus": "var(--c-primary)", "--input-radius": "var(--radius-md)", - "--input-color": "var(--color-text-1)", + "--input-color": "var(--c-text)", "--input-placeholder-color": "var(--color-text-placeholder)", "--input-height-sm": "1.75rem", "--input-height-md": "2.25rem", diff --git a/frontend/editor/src/core/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css index 982abbc26b..8b9949eab5 100644 --- a/frontend/editor/src/core/ui/NavItem.css +++ b/frontend/editor/src/core/ui/NavItem.css @@ -6,7 +6,7 @@ padding: 0.4375rem 0.75rem; margin: 0.0625rem 0.5rem; border-radius: var(--radius-lg); - color: var(--color-nav-text); + color: var(--c-text-subtle); font-size: 0.8125rem; font-weight: 400; transition: @@ -15,16 +15,16 @@ text-align: left; } .sui-navitem:hover { - background: var(--color-nav-hover); - color: var(--color-nav-hover-text); + background: var(--c-hover); + color: var(--c-text-muted); } .sui-navitem.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); + background: var(--c-primary-subtle); + color: var(--c-accent-fg); font-weight: 500; } .sui-navitem.is-active:hover { - background: var(--color-nav-active); + background: var(--c-primary-subtle); } .sui-navitem__icon { width: 1rem; @@ -43,7 +43,7 @@ margin-left: auto; } .sui-navitem:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: 0.125rem; } @@ -61,7 +61,7 @@ border-radius: 3px; } .sui-navitem[data-accent="blue"]::before { - background: var(--color-blue); + background: var(--c-primary); } .sui-navitem[data-accent="purple"]::before { background: var(--color-purple); @@ -76,7 +76,7 @@ background: var(--color-red); } .sui-navitem[data-accent="blue"] .sui-navitem__icon { - color: var(--color-blue); + color: var(--c-primary); } .sui-navitem[data-accent="purple"] .sui-navitem__icon { color: var(--color-purple); diff --git a/frontend/editor/src/core/ui/NavItem.stories.tsx b/frontend/editor/src/core/ui/NavItem.stories.tsx index 7ceb981d79..52d74138bc 100644 --- a/frontend/editor/src/core/ui/NavItem.stories.tsx +++ b/frontend/editor/src/core/ui/NavItem.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { NavItem } from "@app/ui/NavItem"; import { SectionDivider } from "@app/ui/SectionDivider"; -function Dot({ color = "var(--color-blue)" }: { color?: string }) { +function Dot({ color = "var(--c-primary)" }: { color?: string }) { return ( = {
@@ -55,8 +55,8 @@ export const WithTrailingBadge: Story = { @@ -75,8 +75,8 @@ export const InContext_UsageMeter: Story = { marginBottom: 6, }} > - Docs processed - + Docs processed + 412 / 500
diff --git a/frontend/editor/src/core/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx index 570fc43099..e6ca30c0e1 100644 --- a/frontend/editor/src/core/ui/ProgressBar.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.tsx @@ -45,10 +45,10 @@ export function ProgressBar({ ? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))" : v >= 0.8 ? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))" - : "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + : "linear-gradient(90deg, var(--c-primary), color-mix(in srgb, var(--c-primary) 70%, white))"; } else { fill = - "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))"; + "linear-gradient(90deg, var(--c-primary), color-mix(in srgb, var(--c-primary) 70%, white))"; } } return ( diff --git a/frontend/editor/src/core/ui/Radio.css b/frontend/editor/src/core/ui/Radio.css index 162d0a9d5e..7000c12219 100644 --- a/frontend/editor/src/core/ui/Radio.css +++ b/frontend/editor/src/core/ui/Radio.css @@ -17,7 +17,7 @@ gap: var(--space-2); cursor: pointer; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-radio--disabled { opacity: 0.5; @@ -37,8 +37,8 @@ height: 1rem; margin-top: 0.0625rem; border-radius: 50%; - border: 1.5px solid var(--color-border-hover); - background: var(--color-surface); + border: 1.5px solid var(--c-border-strong); + background: var(--c-surface); display: inline-flex; align-items: center; justify-content: center; @@ -47,12 +47,12 @@ } .sui-radio__input:focus-visible + .sui-radio__dot { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } .sui-radio__input:checked + .sui-radio__dot { - border-color: var(--color-blue); + border-color: var(--c-primary); } .sui-radio__input:checked + .sui-radio__dot::after { @@ -60,7 +60,7 @@ width: 0.5rem; height: 0.5rem; border-radius: 50%; - background: var(--color-blue); + background: var(--c-primary); } .sui-radio__text { @@ -68,10 +68,10 @@ flex-direction: column; } .sui-radio__label { - color: var(--color-text-1); + color: var(--c-text); font-weight: 500; } .sui-radio__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/SectionDivider.css b/frontend/editor/src/core/ui/SectionDivider.css index 8999f6f0a3..ddfa8f0363 100644 --- a/frontend/editor/src/core/ui/SectionDivider.css +++ b/frontend/editor/src/core/ui/SectionDivider.css @@ -1,5 +1,5 @@ .sui-divider { height: 0.0625rem; - background: var(--color-sidebar-divider); + background: var(--c-border-subtle); width: 100%; } diff --git a/frontend/editor/src/core/ui/SectionDivider.stories.tsx b/frontend/editor/src/core/ui/SectionDivider.stories.tsx index 8cd433e83a..dd758dfd39 100644 --- a/frontend/editor/src/core/ui/SectionDivider.stories.tsx +++ b/frontend/editor/src/core/ui/SectionDivider.stories.tsx @@ -13,9 +13,9 @@ type Story = StoryObj; export const Default: Story = { render: () => (
-

Section above

+

Section above

-

Section below

+

Section below

), }; @@ -25,11 +25,11 @@ export const InContext_SidebarGroups: Story = {
diff --git a/frontend/editor/src/core/ui/SectionHeader.css b/frontend/editor/src/core/ui/SectionHeader.css index 63a354cc23..8a534d09a1 100644 --- a/frontend/editor/src/core/ui/SectionHeader.css +++ b/frontend/editor/src/core/ui/SectionHeader.css @@ -17,14 +17,14 @@ button.sui-sectionhdr { font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-sectionhdr__count { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-sectionhdr__chevron { - color: var(--color-text-4); + color: var(--c-text-subtle); flex-shrink: 0; transition: transform var(--motion-fast); } diff --git a/frontend/editor/src/core/ui/Select.css b/frontend/editor/src/core/ui/Select.css index b01cf2ecd1..7ced297651 100644 --- a/frontend/editor/src/core/ui/Select.css +++ b/frontend/editor/src/core/ui/Select.css @@ -2,18 +2,18 @@ position: relative; display: inline-flex; align-items: center; - background: var(--color-surface); - border: 1px solid var(--color-border-input); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - color: var(--color-text-1); + color: var(--c-text); transition: border-color var(--motion-fast), box-shadow var(--motion-fast); } .sui-select:focus-within { - border-color: var(--color-blue); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent); + border-color: var(--c-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--c-primary) 16%, transparent); } .sui-select--sm { @@ -53,8 +53,8 @@ * 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); + background-color: var(--c-surface); + color: var(--c-text); } [data-theme="dark"] .sui-select__el { color-scheme: dark; @@ -75,5 +75,5 @@ display: inline-flex; align-items: center; pointer-events: none; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx index fa2d5346b7..242ddf9740 100644 --- a/frontend/editor/src/core/ui/Select.tsx +++ b/frontend/editor/src/core/ui/Select.tsx @@ -7,11 +7,11 @@ import { useInputAria } from "@app/ui/ariaForwarding"; import "@app/ui/MantineForms.css"; const SUI_INPUT_VARS = { - "--input-bg": "var(--color-surface)", - "--input-bd": "var(--color-border-input)", - "--input-bd-focus": "var(--color-blue)", + "--input-bg": "var(--c-surface)", + "--input-bd": "var(--c-border)", + "--input-bd-focus": "var(--c-primary)", "--input-radius": "var(--radius-md)", - "--input-color": "var(--color-text-1)", + "--input-color": "var(--c-text)", "--input-placeholder-color": "var(--color-text-placeholder)", "--input-height-sm": "1.75rem", "--input-height-md": "2.25rem", diff --git a/frontend/editor/src/core/ui/SettingsRow.css b/frontend/editor/src/core/ui/SettingsRow.css index f374a5fdab..18d707c489 100644 --- a/frontend/editor/src/core/ui/SettingsRow.css +++ b/frontend/editor/src/core/ui/SettingsRow.css @@ -13,11 +13,11 @@ .sui-settingsrow__label { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } .sui-settingsrow__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.125rem; } .sui-settingsrow__control { diff --git a/frontend/editor/src/core/ui/SettingsRow.stories.tsx b/frontend/editor/src/core/ui/SettingsRow.stories.tsx index 4527ef2689..792171942b 100644 --- a/frontend/editor/src/core/ui/SettingsRow.stories.tsx +++ b/frontend/editor/src/core/ui/SettingsRow.stories.tsx @@ -48,7 +48,7 @@ export const List: Story = { key={r.label} style={{ padding: "0.7rem 0.875rem", - borderTop: i > 0 ? "1px solid var(--color-border)" : undefined, + borderTop: i > 0 ? "1px solid var(--c-border)" : undefined, }} > { const [active, setActive] = useState("profile"); return ( -
+
} > -

+

Content for the “{LABELS[active]}” section renders here.

diff --git a/frontend/editor/src/core/ui/Skeleton.css b/frontend/editor/src/core/ui/Skeleton.css index 98da3c7dbe..42dccfb5be 100644 --- a/frontend/editor/src/core/ui/Skeleton.css +++ b/frontend/editor/src/core/ui/Skeleton.css @@ -2,9 +2,9 @@ display: inline-block; background: linear-gradient( 90deg, - var(--color-bg-muted) 0%, - var(--color-bg-hover) 50%, - var(--color-bg-muted) 100% + var(--c-surface-sunken) 0%, + var(--c-hover) 50%, + var(--c-surface-sunken) 100% ); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; diff --git a/frontend/editor/src/core/ui/Slider.css b/frontend/editor/src/core/ui/Slider.css index 005ca0cbba..7be91a36c6 100644 --- a/frontend/editor/src/core/ui/Slider.css +++ b/frontend/editor/src/core/ui/Slider.css @@ -17,10 +17,10 @@ border-radius: var(--radius-pill); background: linear-gradient( 90deg, - var(--color-blue) 0%, - var(--color-blue) var(--slider-pct), - var(--color-bg-muted) var(--slider-pct), - var(--color-bg-muted) 100% + var(--c-primary) 0%, + var(--c-primary) var(--slider-pct), + var(--c-surface-sunken) var(--slider-pct), + var(--c-surface-sunken) 100% ); outline: none; } @@ -32,7 +32,7 @@ height: 1rem; border-radius: 50%; background: #fff; - border: 2px solid var(--color-blue); + border: 2px solid var(--c-primary); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; transition: transform var(--motion-fast); @@ -43,13 +43,13 @@ height: 1rem; border-radius: 50%; background: #fff; - border: 2px solid var(--color-blue); + border: 2px solid var(--c-primary); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; } .sui-slider__input:focus-visible::-webkit-slider-thumb { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } @@ -64,5 +64,5 @@ font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; - color: var(--color-text-2); + color: var(--c-text-muted); } diff --git a/frontend/editor/src/core/ui/Spinner.stories.tsx b/frontend/editor/src/core/ui/Spinner.stories.tsx index 9435ffec3a..f2bc56a9ac 100644 --- a/frontend/editor/src/core/ui/Spinner.stories.tsx +++ b/frontend/editor/src/core/ui/Spinner.stories.tsx @@ -31,7 +31,7 @@ export const SizeRow: Story = { export const InheritsColor: Story = { render: () => (
- + diff --git a/frontend/editor/src/core/ui/Stack.stories.tsx b/frontend/editor/src/core/ui/Stack.stories.tsx index d35a40928c..20f6948632 100644 --- a/frontend/editor/src/core/ui/Stack.stories.tsx +++ b/frontend/editor/src/core/ui/Stack.stories.tsx @@ -17,7 +17,7 @@ function Box({ children }: { children: React.ReactNode }) {
{(["1", "2", "4", "6"] as const).map((gap) => ( -
+
gap {gap}
A @@ -59,7 +59,7 @@ export const InCard: Story = {
Card title
-
+
Stack is the default vertical container — it's how you compose every card body, list, and form section.
diff --git a/frontend/editor/src/core/ui/StatTile.css b/frontend/editor/src/core/ui/StatTile.css index c29455fc72..0fdc22c8ca 100644 --- a/frontend/editor/src/core/ui/StatTile.css +++ b/frontend/editor/src/core/ui/StatTile.css @@ -9,14 +9,14 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } .sui-stat__value { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } @@ -34,6 +34,6 @@ .sui-stat__value code { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--color-text-2); + color: var(--c-text-muted); word-break: break-all; } diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index 62f6fb0f3a..230c99b40d 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -8,15 +8,15 @@ letter-spacing: 0.01em; border: 1px solid transparent; line-height: 1; - color: var(--sui-status-c, var(--color-text-3)); + color: var(--sui-status-c, var(--c-text-subtle)); background: color-mix( in srgb, - var(--sui-status-c, var(--color-text-3)) 12%, + var(--sui-status-c, var(--c-text-subtle)) 12%, transparent ); border-color: color-mix( in srgb, - var(--sui-status-c, var(--color-text-3)) 28%, + var(--sui-status-c, var(--c-text-subtle)) 28%, transparent ); } @@ -51,9 +51,9 @@ /* Neutral keeps the plain muted surface rather than an accent tint. */ .sui-status--neutral { - color: var(--color-text-3); - background: var(--color-bg-muted); - border-color: var(--color-border-light); + color: var(--c-text-subtle); + background: var(--c-surface-sunken); + border-color: var(--c-border-subtle); } /* Tones only pick the accent; the base rule builds the fill + border. `-dark` is theme-adaptive, so text stays legible on the pale fill in both themes. */ @@ -67,7 +67,7 @@ --sui-status-c: var(--color-red-dark); } .sui-status--info { - --sui-status-c: var(--color-blue-dark); + --sui-status-c: var(--c-primary-hover); } .sui-status--purple { --sui-status-c: var(--color-purple-dark); diff --git a/frontend/editor/src/core/ui/StepIndicator.css b/frontend/editor/src/core/ui/StepIndicator.css index d5d46e7bd5..8c364d9c56 100644 --- a/frontend/editor/src/core/ui/StepIndicator.css +++ b/frontend/editor/src/core/ui/StepIndicator.css @@ -6,7 +6,7 @@ .sui-steps__bar { flex: 1; border-radius: 999px; - background: var(--color-border); + background: var(--c-border); transition: background var(--motion-fast); } .sui-steps--md .sui-steps__bar { @@ -17,11 +17,11 @@ } /* Completed steps: solid accent. */ .sui-steps__bar[data-state="done"] { - background: var(--color-blue); + background: var(--c-primary); } /* Current step: solid accent + a soft ring so it reads as "you are here". */ .sui-steps__bar[data-state="current"] { - background: var(--color-blue); + background: var(--c-primary); box-shadow: 0 0 0 0.1875rem - color-mix(in srgb, var(--color-blue) 22%, transparent); + color-mix(in srgb, var(--c-primary) 22%, transparent); } diff --git a/frontend/editor/src/core/ui/Table.css b/frontend/editor/src/core/ui/Table.css index 3b17a6c44d..696bbc8afb 100644 --- a/frontend/editor/src/core/ui/Table.css +++ b/frontend/editor/src/core/ui/Table.css @@ -1,9 +1,9 @@ .sui-table-wrap { width: 100%; overflow-x: auto; - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); } .sui-table { @@ -15,12 +15,12 @@ .sui-table__th { text-align: left; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 0.625rem 0.875rem; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--c-border); white-space: nowrap; } .sui-table__th--right, @@ -34,8 +34,8 @@ .sui-table__td { padding: 0.625rem 0.875rem; - color: var(--color-text-2); - border-bottom: 1px solid var(--color-border-light); + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); vertical-align: middle; } .sui-table tbody tr:last-child .sui-table__td { @@ -47,15 +47,15 @@ transition: background var(--motion-fast); } .sui-table__row--interactive:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .sui-table__row--interactive:focus-visible { - outline: 0.125rem solid var(--color-blue); + outline: 0.125rem solid var(--c-primary); outline-offset: -0.125rem; } .sui-table__empty { padding: 2rem; text-align: center; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/Tabs.css b/frontend/editor/src/core/ui/Tabs.css index b9dd365fc7..9fa18ce5b4 100644 --- a/frontend/editor/src/core/ui/Tabs.css +++ b/frontend/editor/src/core/ui/Tabs.css @@ -7,7 +7,7 @@ .sui-tabs--pill { } .sui-tabs--underline { - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); padding-bottom: var(--space-1); gap: var(--space-3); } @@ -18,7 +18,7 @@ gap: var(--space-1_5); padding: var(--space-1_5) var(--space-3); font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); background: transparent; border: 1px solid transparent; transition: @@ -28,8 +28,8 @@ } .sui-tabs__tab:hover:not(.is-disabled) { - color: var(--color-text-1); - background: var(--color-bg-hover); + color: var(--c-text); + background: var(--c-hover); } .sui-tabs__tab.is-disabled { @@ -42,9 +42,9 @@ border-radius: var(--radius-pill); } .sui-tabs--pill .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--color-blue)); - background: var(--color-blue-light); - border-color: var(--sui-tab-accent, var(--color-blue-border)); + color: var(--sui-tab-accent, var(--c-primary)); + background: var(--c-primary-tint); + border-color: var(--sui-tab-accent, var(--c-primary-border)); font-weight: 500; } @@ -56,8 +56,8 @@ margin-bottom: -1px; } .sui-tabs--underline .sui-tabs__tab.is-active { - color: var(--sui-tab-accent, var(--color-blue)); - border-bottom-color: var(--sui-tab-accent, var(--color-blue)); + color: var(--sui-tab-accent, var(--c-primary)); + border-bottom-color: var(--sui-tab-accent, var(--c-primary)); font-weight: 500; } @@ -69,7 +69,7 @@ .sui-tabs__count { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .sui-tabs__tab.is-active .sui-tabs__count { diff --git a/frontend/editor/src/core/ui/Toast.css b/frontend/editor/src/core/ui/Toast.css index a1ca3a67bf..eff290522d 100644 --- a/frontend/editor/src/core/ui/Toast.css +++ b/frontend/editor/src/core/ui/Toast.css @@ -16,8 +16,8 @@ align-items: flex-start; gap: var(--space-3); padding: var(--space-3) var(--space-4); - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-left-width: 3px; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); @@ -25,7 +25,7 @@ } .sui-toast--info { - border-left-color: var(--color-blue); + border-left-color: var(--c-primary); } .sui-toast--success { border-left-color: var(--color-green); @@ -44,10 +44,10 @@ } .sui-toast__title { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .sui-toast__desc { - color: var(--color-text-3); + color: var(--c-text-subtle); margin-top: 0.125rem; line-height: 1.45; } diff --git a/frontend/editor/src/core/ui/ToggleSwitch.css b/frontend/editor/src/core/ui/ToggleSwitch.css index f5025ebfbe..1b6aa90528 100644 --- a/frontend/editor/src/core/ui/ToggleSwitch.css +++ b/frontend/editor/src/core/ui/ToggleSwitch.css @@ -34,7 +34,7 @@ transition: transform var(--motion-base); } .sui-toggle input:checked + .sui-toggle__track { - background: var(--color-blue); + background: var(--c-primary); } .sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb { transform: translateX(1rem); @@ -65,14 +65,14 @@ .sui-toggle__label { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-2); + color: var(--c-text-muted); } .sui-toggle__desc { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .sui-toggle input:focus-visible + .sui-toggle__track { - outline: 2px solid var(--color-blue); + outline: 2px solid var(--c-primary); outline-offset: 2px; } diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 1d47b4ec89..404f98cc52 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -2,24 +2,26 @@ * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ .sui-acc-default { - --_solid: var(--color-blue); - --_solid-hover: var(--color-blue-dark); + --_solid: var(--c-primary); + --_solid-hover: var(--c-primary-hover); --_on: #ffffff; - --_text: var(--color-blue-dark); - --_bd: var(--color-blue-border); - --_tint: color-mix(in srgb, var(--color-blue) 12%, transparent); + --_text: var(--c-primary-hover); + --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); + --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); } html[data-app-theme="custom"] .sui-acc-default { --_on: var(--c-text-on-primary); } +/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the + fill and the outline/text are the SAME red in both light and dark. */ .sui-acc-danger { - --_solid: var(--color-red); - --_solid-hover: var(--color-red-dark); + --_solid: var(--p-red-500); + --_solid-hover: var(--p-red-600); --_on: #ffffff; - --_text: var(--color-red-dark); - --_bd: var(--color-red-border); - --_tint: color-mix(in srgb, var(--color-red) 12%, transparent); + --_text: var(--p-red-500); + --_bd: color-mix(in srgb, var(--p-red-500) 45%, transparent); + --_tint: color-mix(in srgb, var(--p-red-500) 12%, transparent); } .sui-acc-success { --_solid: var(--color-green); @@ -49,57 +51,57 @@ html[data-app-theme="custom"] .sui-acc-default { /* neutral: low-emphasis grey — no palette token, so explicit. */ .sui-acc-neutral { - --_solid: #475569; - --_solid-hover: #334155; + --_solid: var(--p-c-475569); + --_solid-hover: var(--p-c-334155); --_on: #ffffff; - --_text: #475569; - --_bd: #cbd5e1; - --_tint: rgba(71, 85, 105, 0.1); + --_text: var(--p-c-475569); + --_bd: var(--p-c-cbd5e1); + --_tint: color-mix(in srgb, var(--p-c-475569) 10%, transparent); } [data-theme="dark"] .sui-acc-neutral { - --_solid: #64748b; - --_solid-hover: #475569; - --_text: #cbd5e1; - --_bd: #334155; - --_tint: rgba(148, 163, 184, 0.16); + --_solid: var(--p-c-64748b); + --_solid-hover: var(--p-c-475569); + --_text: var(--p-c-cbd5e1); + --_bd: var(--p-c-334155); + --_tint: color-mix(in srgb, var(--p-c-94a3b8) 16%, transparent); } /* brand: Stirling red — a bespoke brand colour, not part of the token palette. */ .sui-acc-brand { - --_solid: #8e3131; - --_solid-hover: #7a2929; + --_solid: var(--p-brand-red-650); + --_solid-hover: var(--p-brand-red-700); --_on: #ffffff; - --_text: #8e3131; - --_bd: #d9a8a8; - --_tint: rgba(142, 49, 49, 0.09); + --_text: var(--p-brand-red-650); + --_bd: var(--p-brand-red-200); + --_tint: color-mix(in srgb, var(--p-brand-red-650) 9%, transparent); } [data-theme="dark"] .sui-acc-brand { - --_text: #d98a8a; - --_bd: #5a2424; - --_tint: rgba(217, 138, 138, 0.16); + --_text: var(--p-brand-red-300); + --_bd: var(--p-brand-red-900); + --_tint: color-mix(in srgb, var(--p-brand-red-300) 16%, transparent); } /* ai: multi-hue gradient for AI features — no single-colour token. */ .sui-acc-ai { --_solid: linear-gradient( 135deg, - #8b5cf6 0%, - #6366f1 38%, - #3b82f6 72%, - #22d3ee 100% + var(--p-violet-500) 0%, + var(--p-indigo-500) 38%, + var(--p-blue-500) 72%, + var(--p-cyan-400) 100% ); --_solid-hover: linear-gradient( 135deg, - #8b5cf6 0%, - #6366f1 38%, - #3b82f6 72%, - #22d3ee 100% + var(--p-violet-500) 0%, + var(--p-indigo-500) 38%, + var(--p-blue-500) 72%, + var(--p-cyan-400) 100% ); --_on: #ffffff; - --_text: #6366f1; - --_bd: #c7d2fe; - --_tint: rgba(99, 102, 241, 0.1); + --_text: var(--p-indigo-500); + --_bd: var(--p-indigo-200); + --_tint: color-mix(in srgb, var(--p-indigo-500) 10%, transparent); } [data-theme="dark"] .sui-acc-ai { - --_text: #a5b4fc; - --_bd: #3730a3; - --_tint: rgba(129, 140, 248, 0.18); + --_text: var(--p-indigo-300); + --_bd: var(--p-indigo-800); + --_tint: color-mix(in srgb, var(--p-indigo-400) 18%, transparent); } diff --git a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx index 96bddff2af..4f9222c2b0 100644 --- a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx +++ b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx @@ -15,7 +15,10 @@ import { connectionModeService } from "@app/services/connectionModeService"; const ONBOARDING_KEY = "stirling-desktop-onboarding-seen"; -const SIGN_IN_GRADIENT: [string, string] = ["#3B82F6", "#7C3AED"]; +const SIGN_IN_GRADIENT: [string, string] = [ + "var(--c-hue-blue)", + "var(--c-hue-violet)", +]; /** * Desktop-specific onboarding modal. @@ -76,7 +79,7 @@ export function DesktopOnboardingModal() { content: { overflow: "hidden", border: "none", - background: "var(--bg-surface)", + background: "var(--c-surface)", maxHeight: "90vh", display: "flex", flexDirection: "column", @@ -158,7 +161,7 @@ export function DesktopOnboardingModal() {
{welcomeSlide.body}
- +
diff --git a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css index 55ab193a4c..3483c3563a 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css +++ b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css @@ -30,7 +30,7 @@ } .oauth-button-vertical-desktop:hover:not(:disabled) { - background-color: var(--bg-raised); + background-color: var(--c-surface-raised); } .oauth-button-vertical-desktop:disabled { diff --git a/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx b/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx index 1dc41e9497..d573f189af 100644 --- a/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx +++ b/frontend/editor/src/desktop/components/tools/toolPicker/ToolPickerFooterExtensions.tsx @@ -40,8 +40,8 @@ export function ToolPickerFooterExtensions() { px="sm" py={10} style={{ - borderTop: "1px solid var(--border-default)", - background: "var(--bg-toolbar)", + borderTop: "1px solid var(--c-border)", + background: "var(--c-bg-raised)", flexShrink: 0, }} > diff --git a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx index fc715e6696..1b7900956e 100644 --- a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx +++ b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx @@ -63,7 +63,7 @@ export default function LoginHeader({ aria-label={t("common.close", "Close")} style={{ flexShrink: 0, - color: "var(--text-secondary)", + color: "var(--c-text-muted)", outline: "none", }} > diff --git a/frontend/editor/src/output.css b/frontend/editor/src/output.css index c77b20bc87..09e3931f55 100644 --- a/frontend/editor/src/output.css +++ b/frontend/editor/src/output.css @@ -1,362 +1,375 @@ -*, ::before, ::after { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: +*, +::before, +::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: +; } ::backdrop { - --tw-border-spacing-x: 0; - --tw-border-spacing-y: 0; - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-pan-x: ; - --tw-pan-y: ; - --tw-pinch-zoom: ; - --tw-scroll-snap-strictness: proximity; - --tw-gradient-from-position: ; - --tw-gradient-via-position: ; - --tw-gradient-to-position: ; - --tw-ordinal: ; - --tw-slashed-zero: ; - --tw-numeric-figure: ; - --tw-numeric-spacing: ; - --tw-numeric-fraction: ; - --tw-ring-inset: ; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-color: rgb(59 130 246 / 0.5); - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; - --tw-shadow: 0 0 #0000; - --tw-shadow-colored: 0 0 #0000; - --tw-blur: ; - --tw-brightness: ; - --tw-contrast: ; - --tw-grayscale: ; - --tw-hue-rotate: ; - --tw-invert: ; - --tw-saturate: ; - --tw-sepia: ; - --tw-drop-shadow: ; - --tw-backdrop-blur: ; - --tw-backdrop-brightness: ; - --tw-backdrop-contrast: ; - --tw-backdrop-grayscale: ; - --tw-backdrop-hue-rotate: ; - --tw-backdrop-invert: ; - --tw-backdrop-opacity: ; - --tw-backdrop-saturate: ; - --tw-backdrop-sepia: ; - --tw-contain-size: ; - --tw-contain-layout: ; - --tw-contain-paint: ; - --tw-contain-style: + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: +; } .mb-3 { - margin-bottom: 0.75rem + margin-bottom: 0.75rem; } .mb-4 { - margin-bottom: 1rem + margin-bottom: 1rem; } .mr-2 { - margin-right: 0.5rem + margin-right: 0.5rem; } .mt-2 { - margin-top: 0.5rem + margin-top: 0.5rem; } .mt-4 { - margin-top: 1rem + margin-top: 1rem; } .block { - display: block + display: block; } .flex { - display: flex + display: flex; } .hidden { - display: none + display: none; } .h-6 { - height: 1.5rem + height: 1.5rem; } .h-full { - height: 100% + height: 100%; } .h-screen { - height: 100vh + height: 100vh; } .w-6 { - width: 1.5rem + width: 1.5rem; } .w-64 { - width: 16rem + width: 16rem; } .w-72 { - width: 18rem + width: 18rem; } .w-full { - width: 100% + width: 100%; } .max-w-3xl { - max-width: 48rem + max-width: 48rem; } .flex-1 { - flex: 1 1 0% + flex: 1 1 0%; } .cursor-pointer { - cursor: pointer + cursor: pointer; } .list-disc { - list-style-type: disc + list-style-type: disc; } .flex-col { - flex-direction: column + flex-direction: column; } .items-center { - align-items: center + align-items: center; } .justify-center { - justify-content: center + justify-content: center; } .justify-between { - justify-content: space-between + justify-content: space-between; } .space-x-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.5rem * var(--tw-space-x-reverse)); - margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))) + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } .space-x-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.75rem * var(--tw-space-x-reverse)); - margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))) + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); } .space-y-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); } .space-y-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); } .space-y-4 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(1rem * var(--tw-space-y-reverse)) + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); } .overflow-hidden { - overflow: hidden + overflow: hidden; } .overflow-y-auto { - overflow-y: auto + overflow-y: auto; } .rounded { - border-radius: 0.25rem + border-radius: 0.25rem; } .rounded-md { - border-radius: 0.375rem + border-radius: 0.375rem; } .border { - border-width: 1px + border-width: 1px; } .border-b { - border-bottom-width: 1px + border-bottom-width: 1px; } .border-l { - border-left-width: 1px + border-left-width: 1px; } .border-r { - border-right-width: 1px + border-right-width: 1px; } .border-none { - border-style: none + border-style: none; } .bg-blue-600 { - --tw-bg-opacity: 1; - background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } .bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); } .bg-gray-50 { - --tw-bg-opacity: 1; - background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); } .bg-green-600 { - --tw-bg-opacity: 1; - background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(22 163 74 / var(--tw-bg-opacity, 1)); } .bg-white { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); } .p-2 { - padding: 0.5rem + padding: 0.5rem; } .p-4 { - padding: 1rem + padding: 1rem; } .px-2 { - padding-left: 0.5rem; - padding-right: 0.5rem + padding-left: 0.5rem; + padding-right: 0.5rem; } .px-4 { - padding-left: 1rem; - padding-right: 1rem + padding-left: 1rem; + padding-right: 1rem; } .py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem + padding-top: 0.25rem; + padding-bottom: 0.25rem; } .py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem + padding-top: 0.5rem; + padding-bottom: 0.5rem; } .pl-5 { - padding-left: 1.25rem + padding-left: 1.25rem; } .text-left { - text-align: left + text-align: left; } .text-center { - text-align: center + text-align: center; } .text-lg { - font-size: 1.125rem; - line-height: 1.75rem + font-size: 1.125rem; + line-height: 1.75rem; } .text-sm { - font-size: 0.875rem; - line-height: 1.25rem + font-size: 0.875rem; + line-height: 1.25rem; } .text-xl { - font-size: 1.25rem; - line-height: 1.75rem + font-size: 1.25rem; + line-height: 1.75rem; } .text-xs { - font-size: 0.75rem; - line-height: 1rem + font-size: 0.75rem; + line-height: 1rem; } .font-medium { - font-weight: 500 + font-weight: 500; } .font-semibold { - font-weight: 600 + font-weight: 600; } .leading-none { - line-height: 1 + line-height: 1; } .text-blue-600 { - --tw-text-opacity: 1; - color: rgb(37 99 235 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity, 1)); } .text-gray-500 { - --tw-text-opacity: 1; - color: rgb(107 114 128 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } .text-gray-600 { - --tw-text-opacity: 1; - color: rgb(75 85 99 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); } .text-red-500 { - --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity, 1)); } .text-red-600 { - --tw-text-opacity: 1; - color: rgb(220 38 38 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity, 1)); } .text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity, 1)) + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } .underline { - text-decoration-line: underline + text-decoration-line: underline; } .shadow { - --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: + 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .shadow-sm { - --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) + --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: + var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } .grayscale { - --tw-grayscale: grayscale(100%); - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) + --tw-grayscale: grayscale(100%); + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) + var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) + var(--tw-sepia) var(--tw-drop-shadow); } .filter { - filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) + var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) + var(--tw-sepia) var(--tw-drop-shadow); } .hover\:bg-blue-700:hover { - --tw-bg-opacity: 1; - background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); } .hover\:bg-gray-200:hover { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); } .hover\:bg-green-700:hover { - --tw-bg-opacity: 1; - background-color: rgb(21 128 61 / var(--tw-bg-opacity, 1)) + --tw-bg-opacity: 1; + background-color: rgb(21 128 61 / var(--tw-bg-opacity, 1)); } .hover\:underline:hover { - text-decoration-line: underline + text-decoration-line: underline; } diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index a01026480a..6535832399 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -10,8 +10,8 @@ height: 100vh; height: 100dvh; /* mobile browser chrome shrinks the viewport; dvh tracks it */ overflow: hidden; - background: var(--color-bg); - color: var(--color-text-2); + background: var(--c-bg); + color: var(--c-text-muted); } .portal-shell__main { @@ -55,7 +55,7 @@ position: fixed; inset: 0; z-index: 75; - background: rgba(15, 23, 42, 0.55); + background: var(--c-overlay); } @media (max-width: 48rem) { diff --git a/frontend/editor/src/portal/components/AssistantButton.css b/frontend/editor/src/portal/components/AssistantButton.css index 107ff7c1ac..6ae6e34e46 100644 --- a/frontend/editor/src/portal/components/AssistantButton.css +++ b/frontend/editor/src/portal/components/AssistantButton.css @@ -8,14 +8,14 @@ display: inline-flex; align-items: center; justify-content: center; - color: #fff; + color: var(--c-text-on-primary); background: linear-gradient( 135deg, - var(--color-blue) 0%, + var(--c-primary) 0%, var(--color-purple) 100% ); box-shadow: - 0 0.5rem 1.25rem rgba(59, 130, 246, 0.35), + 0 0.5rem 1.25rem color-mix(in srgb, var(--c-primary) 35%, transparent), inset 0 0.0625rem 0 rgba(255, 255, 255, 0.2); transition: transform var(--motion-base), @@ -26,7 +26,7 @@ .portal-assistant-btn:hover { transform: scale(1.08); box-shadow: - 0 0.75rem 1.75rem rgba(59, 130, 246, 0.45), + 0 0.75rem 1.75rem color-mix(in srgb, var(--c-primary) 45%, transparent), inset 0 0.0625rem 0 rgba(255, 255, 255, 0.25); } diff --git a/frontend/editor/src/portal/components/AssistantPanel.css b/frontend/editor/src/portal/components/AssistantPanel.css index 90a66f169d..2e5edc907f 100644 --- a/frontend/editor/src/portal/components/AssistantPanel.css +++ b/frontend/editor/src/portal/components/AssistantPanel.css @@ -6,8 +6,8 @@ width: min(23.75rem, calc(100vw - 2rem)); height: 32.5rem; max-height: calc(100dvh - 7rem); - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); display: flex; @@ -26,7 +26,7 @@ color: var(--color-text-on-accent); background: linear-gradient( 135deg, - var(--color-blue) 0%, + var(--c-primary) 0%, var(--color-purple) 100% ); } @@ -91,17 +91,17 @@ padding: 0.4375rem 0.625rem; font-size: 0.8125rem; text-align: left; - color: var(--color-text-2); - background: var(--color-surface); - border: 1px solid var(--color-border); + color: var(--c-text-muted); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); transition: border-color var(--motion-fast), background var(--motion-fast); } .portal-assistant__suggestion:hover { - background: var(--color-bg-hover); - border-color: var(--color-blue-border); + background: var(--c-hover); + border-color: var(--c-primary-border); } .portal-assistant__bubble { @@ -116,16 +116,16 @@ .portal-assistant__bubble--user { align-self: flex-end; - background: var(--color-blue); + background: var(--c-primary); color: var(--color-text-on-accent); border-bottom-right-radius: 0.25rem; } .portal-assistant__bubble--assistant { align-self: flex-start; - background: var(--color-surface); - color: var(--color-text-2); - border: 1px solid var(--color-border); + background: var(--c-surface); + color: var(--c-text-muted); + border: 1px solid var(--c-border); border-bottom-left-radius: 0.25rem; } @@ -138,7 +138,7 @@ width: 0.375rem; height: 0.375rem; border-radius: 50%; - background: var(--color-text-5); + background: var(--c-text-subtle); animation: pulse 1.2s ease-in-out infinite; } .portal-assistant__typing span:nth-child(2) { @@ -153,8 +153,8 @@ align-items: center; gap: 0.5rem; padding: 0.625rem 0.75rem; - border-top: 1px solid var(--color-border); - background: var(--color-surface); + border-top: 1px solid var(--c-border); + background: var(--c-surface); } .portal-assistant__input { @@ -162,15 +162,15 @@ font: inherit; font-size: 0.8125rem; background: transparent; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); border-radius: var(--radius-md); padding: 0.4375rem 0.625rem; - color: var(--color-text-1); + color: var(--c-text); outline: none; transition: border-color var(--motion-fast); } .portal-assistant__input:focus { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-assistant__send { diff --git a/frontend/editor/src/portal/components/AssistantPanel.stories.tsx b/frontend/editor/src/portal/components/AssistantPanel.stories.tsx index 92fe3e0bdf..911f389566 100644 --- a/frontend/editor/src/portal/components/AssistantPanel.stories.tsx +++ b/frontend/editor/src/portal/components/AssistantPanel.stories.tsx @@ -18,7 +18,7 @@ const meta: Meta = { parameters: { layout: "fullscreen" }, decorators: [ (S) => ( -
+
diff --git a/frontend/editor/src/portal/components/AuthGate.tsx b/frontend/editor/src/portal/components/AuthGate.tsx index a7f4e58ef5..0af62ee53b 100644 --- a/frontend/editor/src/portal/components/AuthGate.tsx +++ b/frontend/editor/src/portal/components/AuthGate.tsx @@ -19,7 +19,7 @@ function FullScreenMessage({ children }: { children: ReactNode }) { alignItems: "center", justifyContent: "center", gap: "0.75rem", - color: "var(--color-text-3)", + color: "var(--c-text-subtle)", }} > {children} diff --git a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx index b6124bbd25..af7d67eea7 100644 --- a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx +++ b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx @@ -55,7 +55,7 @@ function MockChatContent({ alignItems: "center", justifyContent: "space-between", padding: "14px 16px 10px", - borderBottom: "1px solid var(--color-border, #e3e8ee)", + borderBottom: "1px solid var(--c-border, #e3e8ee)", flexShrink: 0, }} > @@ -65,7 +65,7 @@ function MockChatContent({ shape="circle" onClick={onClose} aria-label="Close chat" - style={{ color: "var(--color-text-4, #64748b)" }} + style={{ color: "var(--c-text-subtle, #64748b)" }} > ✕ @@ -91,7 +91,7 @@ function MockChatContent({ background: m.role === "user" ? "#3b82f6" - : "var(--color-bg-muted, #f3f4f6)", + : "var(--c-surface-sunken, #f3f4f6)", color: m.role === "user" ? "#fff" : "inherit", borderRadius: 10, padding: "8px 12px", @@ -108,17 +108,17 @@ function MockChatContent({
What do you want to do? @@ -149,7 +149,7 @@ function ChatFABWidgetDemo({ width: "100%", height: "100%", overflow: "hidden", - background: "var(--color-bg, #f8f9fb)", + background: "var(--c-bg, #f8f9fb)", }} > {/* FAB button */} @@ -249,7 +249,7 @@ function ChatFABFullFlowDemo() { padding: "4px 10px", borderRadius: 6, background: - step === s ? "#3b82f6" : "var(--color-bg-muted, #f3f4f6)", + step === s ? "#3b82f6" : "var(--c-surface-sunken, #f3f4f6)", color: step === s ? "#fff" : "inherit", fontWeight: step === s ? 600 : 400, }} diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.css b/frontend/editor/src/portal/components/DownloadEditorModal.css index fba739501f..4742833be8 100644 --- a/frontend/editor/src/portal/components/DownloadEditorModal.css +++ b/frontend/editor/src/portal/components/DownloadEditorModal.css @@ -14,7 +14,7 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-install__section:first-child { margin-top: 0; @@ -27,9 +27,9 @@ gap: 0.875rem; width: 100%; padding: 0.75rem 0.875rem; - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); text-align: left; cursor: pointer; transition: @@ -37,8 +37,8 @@ background var(--motion-fast); } .portal-install__option:hover { - border-color: var(--color-border-input); - background: var(--color-bg-hover); + border-color: var(--c-border); + background: var(--c-hover); } .portal-install__option-icon { @@ -48,8 +48,8 @@ height: 2.25rem; flex-shrink: 0; border-radius: 0.625rem; - color: var(--color-blue); - background: var(--color-blue-light); + color: var(--c-primary); + background: var(--c-primary-tint); } .portal-install__option-text { @@ -60,14 +60,14 @@ .portal-install__option-text strong { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-install__option-text span { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-install__option-chevron { - color: var(--color-text-5); + color: var(--c-text-subtle); flex-shrink: 0; } @@ -88,13 +88,13 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-install__note { margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-install__guide { align-self: flex-start; @@ -106,7 +106,7 @@ background: none; font-size: 0.8125rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); cursor: pointer; } .portal-install__guide:hover { diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 10d8a020b4..e71a50bac3 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -4,8 +4,8 @@ .portal-editor-hero { border-radius: var(--radius-xl); - border: 1px solid var(--color-border-input); - background: var(--color-surface); + border: 1px solid var(--c-border); + background: var(--c-surface); box-shadow: var(--shadow-sm); overflow: hidden; } @@ -15,7 +15,7 @@ align-items: center; gap: 1.25rem; padding: 1rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950)); + background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); } .portal-editor-hero__logo { @@ -123,7 +123,7 @@ .portal-editor-hero__action .portal-editor-hero__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: var(--color-hero-navy); + color: var(--c-hero-dark-cta-text); } .portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); @@ -141,5 +141,5 @@ /* Attached footer strip (setup checklist). */ .portal-editor-hero__footer { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 8d3b7dcbcf..1734d677c3 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -28,7 +28,7 @@ function StirlingMark() { fill="none" aria-hidden > - + = { parameters: { layout: "fullscreen" }, decorators: [ (S) => ( -
+
diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css index a19919e640..0fcb71e678 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ b/frontend/editor/src/portal/components/SetupChecklist.css @@ -21,7 +21,7 @@ width: 100%; padding: 0.6875rem 1.25rem; border: none; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); background: transparent; text-align: left; cursor: pointer; @@ -31,7 +31,7 @@ border-top: none; } .portal-setup__row:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } /* Numbered step marker */ @@ -42,10 +42,10 @@ height: 1.5rem; flex-shrink: 0; border-radius: 50%; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); font-size: 0.75rem; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Completed step: filled green check. */ .portal-setup__num.is-done { @@ -54,7 +54,7 @@ color: #fff; } .portal-setup__row.is-done .portal-setup__text strong { - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-setup__text { @@ -65,12 +65,12 @@ .portal-setup__text strong { font-size: 0.875rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-setup__text span { font-size: 0.75rem; line-height: 1.4; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ── Enterprise upsell rung ── */ @@ -81,10 +81,10 @@ gap: 1rem; flex-wrap: wrap; padding: 0.75rem 1.25rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); background: linear-gradient( 90deg, - color-mix(in srgb, var(--color-blue) 5%, transparent) 0%, + color-mix(in srgb, var(--c-primary) 5%, transparent) 0%, transparent 55% ); } @@ -105,18 +105,18 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-blue-dark); - background: var(--color-blue-light); + color: var(--c-primary-hover); + background: var(--c-primary-tint); } .portal-setup__enterprise-text { margin: 0; font-size: 0.8125rem; line-height: 1.45; - color: var(--color-text-3); + color: var(--c-text-subtle); min-width: 0; } .portal-setup__enterprise-text strong { - color: var(--color-text-1); + color: var(--c-text); font-weight: 700; } diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx index 78af75d188..432be155d7 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx @@ -23,10 +23,10 @@ const meta: Meta = {
diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 1e0096fc4e..c9dfa871ee 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -2,8 +2,8 @@ width: 15rem; height: 100vh; height: 100dvh; /* track mobile browser chrome */ - background: var(--color-sidebar-bg); - border-right: 1px solid var(--color-sidebar-border); + background: var(--c-bg-raised); + border-right: 1px solid var(--c-border); display: flex; flex-direction: column; flex-shrink: 0; @@ -41,7 +41,7 @@ .portal-sidebar--open { transform: translateX(0); visibility: visible; - box-shadow: 0 0.5rem 2rem rgba(15, 23, 42, 0.35); + box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.35); } .portal-sidebar__close { display: inline-flex; @@ -55,7 +55,7 @@ display: flex; align-items: center; gap: 0.5rem; - border-bottom: 1px solid var(--color-sidebar-divider); + border-bottom: 1px solid var(--c-border-subtle); } /* Stirling Processor wordmark (theme-switched in Sidebar.tsx); matches the @@ -67,6 +67,19 @@ flex-shrink: 0; } +/* Show the wordmark that matches the rendered scheme (black text on light, + white text on dark). Keyed on data-mantine-color-scheme so it follows the + actual theme, not the portal's separate (and sometimes stale) theme state. */ +.portal-sidebar__wordmark--dark { + display: none; +} +[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--light { + display: none; +} +[data-mantine-color-scheme="dark"] .portal-sidebar__wordmark--dark { + display: block; +} + /* App switcher (down-arrow → Portal / Editor); button and menu styling live with the shared AppSwitch element. */ .portal-sidebar__app-switch { @@ -86,8 +99,8 @@ /* Each section is a labelled card: a small header above its nav items. */ .portal-sidebar__section { - background: var(--color-surface); - border: 1px solid var(--color-sidebar-divider); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 0.625rem; padding: 0.5rem 0.375rem 0.375rem; display: flex; @@ -101,7 +114,7 @@ font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.02em; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-sidebar__group { @@ -112,7 +125,7 @@ /* Footer */ .portal-sidebar__footer { - border-top: 1px solid var(--color-sidebar-divider); + border-top: 1px solid var(--c-border-subtle); padding: 0.5rem 0.625rem 0.75rem; display: flex; flex-direction: column; diff --git a/frontend/editor/src/portal/components/Sidebar.stories.tsx b/frontend/editor/src/portal/components/Sidebar.stories.tsx index df4d6c1cdb..d40d710adb 100644 --- a/frontend/editor/src/portal/components/Sidebar.stories.tsx +++ b/frontend/editor/src/portal/components/Sidebar.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { style={{ display: "flex", height: "100vh", - background: "var(--color-bg)", + background: "var(--c-bg)", }} > diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 4eec4dcc06..b4f5300d16 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -80,9 +80,17 @@ export function Sidebar() { inert={isMobile && !mobileNavOpen} >
+ {/* Both wordmarks render; CSS shows the right one per the actual color + scheme (data-mantine-color-scheme), so it tracks the rendered theme + rather than the portal's separate theme state. */} {t("portal.shell.sidebar.brandSuffix")} + {t("portal.shell.sidebar.brandSuffix")} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 457291eadb..157ee24ac0 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -4,10 +4,10 @@ .portal-welcome { border-radius: var(--radius-xl); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); overflow: hidden; isolation: isolate; - background: var(--color-surface); + background: var(--c-surface); } /* ── Dark product header strip ── */ @@ -18,7 +18,7 @@ gap: 1rem; flex-wrap: wrap; padding: 0.875rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--p-zinc-950)); + background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); } .portal-welcome__brand { @@ -90,7 +90,7 @@ .portal-welcome__header .portal-welcome__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: var(--color-hero-navy); + color: var(--c-hero-dark-cta-text); } .portal-welcome__header .portal-welcome__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); @@ -99,5 +99,5 @@ /* ── Steps + enterprise (setup checklist) sit directly under the header ── */ .portal-welcome__footer { - background: var(--color-surface); + background: var(--c-surface); } diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 423e917c94..3ab25438db 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -28,7 +28,7 @@ .portal-billing__spend-foot { margin-top: auto; padding-top: 1rem; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } .portal-billing__spend-foot:not(:first-child) { margin-top: 1.25rem; @@ -42,25 +42,25 @@ padding: 0.3rem 0.7rem; font-size: 0.8125rem; font-weight: 500; - color: var(--color-blue); + color: var(--c-primary); background: var(--color-bg-subtle); - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: 999px; cursor: pointer; } .portal-billing__suggested:hover { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-billing__guardrail { margin-top: 0.85rem; padding: 0.7rem 0.85rem; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); background: var(--color-bg-subtle); border-radius: 0.6rem; } .portal-billing__guardrail strong { - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__edit-actions { display: flex; @@ -99,20 +99,20 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-text-3); + color: var(--c-text-subtle); margin-bottom: 0.25rem; } .portal-billing__section-title { font-size: 1.05rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); margin: 0 0 0.25rem; } .portal-billing__section-sub { font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0 0 1rem; } @@ -153,20 +153,20 @@ .portal-billing__checkout-status-title { font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); margin: 0.25rem 0 0; } .portal-billing__checkout-status-body { font-size: 0.9375rem; - color: var(--color-text-2); + color: var(--c-text-muted); margin: 0; max-width: 32rem; } .portal-billing__checkout-status-hint { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0; } @@ -188,7 +188,7 @@ font-size: 1.25rem; font-weight: 600; margin: 0; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__meter-figures { @@ -206,21 +206,21 @@ .portal-billing__meter-num { font-size: 1.75rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__meter-num--muted { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__meter-label { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__meter-track { height: 0.5rem; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); border-radius: 999px; overflow: hidden; margin-bottom: 0.75rem; @@ -228,14 +228,14 @@ .portal-billing__meter-fill { height: 100%; - background: linear-gradient(90deg, var(--color-blue), var(--color-purple)); + background: linear-gradient(90deg, var(--c-primary), var(--color-purple)); border-radius: 999px; transition: width 200ms ease; } .portal-billing__meter-foot { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); margin: 0.75rem 0 0; } @@ -265,12 +265,12 @@ font-weight: 750; line-height: 1; letter-spacing: -0.02em; - color: var(--color-text-1, #0f172a); + color: var(--c-text); font-variant-numeric: tabular-nums; } .paygf-meter__cap { font-size: 0.85rem; - color: var(--color-text-3, #64748b); + color: var(--c-text-subtle); font-variant-numeric: tabular-nums; } .paygf-meter .payg-bar { @@ -283,7 +283,7 @@ align-items: center; gap: 6px 10px; font-size: 0.78rem; - color: var(--color-text-2, #475569); + color: var(--c-text-muted); } /* Status chip (Healthy / Approaching / Cap reached). Solid hex colours @@ -333,7 +333,7 @@ margin-top: 18px; height: 10px; border-radius: 999px; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); overflow: hidden; position: relative; } @@ -343,7 +343,7 @@ transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1); } .payg-bar__fill[data-state="FULL"] { - background: var(--color-blue); + background: var(--c-primary); } .payg-bar__fill[data-state="WARNED"] { background: var(--color-amber); @@ -363,12 +363,12 @@ font-size: 1.25rem; font-weight: 600; margin: 0; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__plan-sub { font-size: 0.9375rem; - color: var(--color-text-2); + color: var(--c-text-muted); margin: 0 0 0.5rem; } @@ -383,7 +383,7 @@ .portal-billing__plan-features li { font-size: 0.875rem; - color: var(--color-text-2); + color: var(--c-text-muted); padding-left: 1.25rem; position: relative; } @@ -405,12 +405,12 @@ .portal-billing__plan-reassure { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__plan-readonly { font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); font-style: italic; margin: 0; } @@ -432,16 +432,16 @@ display: flex; justify-content: space-between; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-billing__breakdown-value { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__breakdown-track { height: 0.5rem; - background: var(--color-bg-subtle, #e5e7eb); + background: var(--color-bg-subtle, var(--c-surface-sunken)); border-radius: 999px; overflow: hidden; } @@ -453,7 +453,7 @@ } .portal-billing__breakdown-fill--blue { - background: var(--color-blue); + background: var(--c-primary); } .portal-billing__breakdown-fill--purple { @@ -478,13 +478,13 @@ flex-direction: column; gap: 0.25rem; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__cap-input { width: 8rem; padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border, #d1d5db); + border: 1px solid var(--c-border); border-radius: 0.375rem; font-size: 0.9375rem; font-family: inherit; @@ -495,7 +495,7 @@ align-items: center; gap: 0.375rem; font-size: 0.875rem; - color: var(--color-text-2); + color: var(--c-text-muted); cursor: pointer; } @@ -512,12 +512,12 @@ .portal-billing__member-name { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__member-email { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__invoice-num { @@ -526,7 +526,7 @@ } .portal-billing__invoice-desc { - color: var(--color-text-1, #0f172a); + color: var(--c-text); font-size: 0.9375rem; } @@ -566,26 +566,26 @@ Same class names + structure so the visual is identical to the SaaS Plan page; when the shared-component move lands, these dedupe to one stylesheet. */ .scc { - --scc-accent: var(--color-blue); - --scc-accent-text: var(--color-blue); - --scc-accent-soft: color-mix(in srgb, var(--color-blue) 12%, transparent); - --scc-accent-border: color-mix(in srgb, var(--color-blue) 25%, transparent); - --scc-chip-bg: var(--color-bg-muted, #f8fafc); - --scc-chip-border: var(--color-border, #e2e8f0); - --scc-text-primary: var(--color-text-1, #0f172a); - --scc-text-secondary: var(--color-text-2, #475569); - --scc-text-muted: var(--color-text-3, #64748b); - --scc-border-strong: var(--color-text-4, #94a3b8); + --scc-accent: var(--c-primary); + --scc-accent-text: var(--c-primary); + --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); + --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); + --scc-chip-bg: var(--c-surface-sunken); + --scc-chip-border: var(--c-border); + --scc-text-primary: var(--c-text); + --scc-text-secondary: var(--c-text-muted); + --scc-text-muted: var(--c-text-subtle); + --scc-border-strong: var(--c-text-subtle); display: flex; flex-direction: column; gap: 14px; margin-top: 0.75rem; } [data-theme="dark"] .scc { - --scc-accent-text: var(--color-blue); - --scc-accent-soft: color-mix(in srgb, var(--color-blue) 16%, transparent); - --scc-chip-bg: var(--color-bg-muted); - --scc-chip-border: var(--color-border); + --scc-accent-text: var(--c-primary); + --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); + --scc-chip-bg: var(--c-surface-sunken); + --scc-chip-border: var(--c-border); } .scc-row { @@ -719,7 +719,7 @@ } .portal-billing__planhead-eyebrow { font-size: 0.78rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__role-pill { display: inline-flex; @@ -732,14 +732,14 @@ white-space: nowrap; } .portal-billing__role-pill[data-leader="true"] { - background: color-mix(in srgb, var(--color-blue) 12%, transparent); - color: var(--color-blue); - border: 1px solid color-mix(in srgb, var(--color-blue) 25%, transparent); + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + color: var(--c-primary); + border: 1px solid color-mix(in srgb, var(--c-primary) 25%, transparent); } .portal-billing__role-pill[data-leader="false"] { - background: var(--color-bg-muted); - color: var(--color-text-3); - border: 1px solid var(--color-border); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); + border: 1px solid var(--c-border); } .portal-billing__planhead-split { display: grid; @@ -751,7 +751,7 @@ .portal-billing__planhead-col--meter { padding-right: 0; padding-left: 22px; - border-left: 1px solid var(--color-border); + border-left: 1px solid var(--c-border); } .portal-billing__planhead-lbl { display: inline-flex; @@ -767,27 +767,27 @@ color: var(--color-green); } .portal-billing__planhead-lbl--meter { - color: var(--color-blue); + color: var(--c-primary); } .portal-billing__planhead-title { margin: 0; font-size: 1.05rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); letter-spacing: -0.01em; line-height: 1.25; } .portal-billing__planhead-body { margin: 5px 0 0; font-size: 0.85rem; - color: var(--color-text-3); + color: var(--c-text-subtle); line-height: 1.5; } /* The period meter merged into the plan-head card, divided from the split. */ .portal-billing__planhead-meter { margin-top: 18px; padding-top: 18px; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } @media (max-width: 640px) { .portal-billing__planhead-split { @@ -801,7 +801,7 @@ padding-left: 0; padding-top: 16px; border-left: none; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } } @@ -820,7 +820,7 @@ border-radius: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-1, #0f172a); + color: var(--c-text); text-decoration: none; border: 1px solid transparent; transition: @@ -829,14 +829,14 @@ } .portal-billing__invoice-link:hover { - background: var(--color-bg-muted, #f1f5f9); - border-color: var(--color-border, #e2e8f0); + background: var(--c-surface-sunken); + border-color: var(--c-border); } .portal-billing__invoice-link:focus-visible { outline: none; - border-color: var(--color-blue); - background: var(--color-bg-muted, #f1f5f9); + border-color: var(--c-primary); + background: var(--c-surface-sunken); } .portal-billing__invoice-footer { @@ -861,7 +861,7 @@ margin: 0; font-size: 1.5rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } /* ── Free PDF Editors (fleet) card ───────────────────────────────────── */ @@ -880,8 +880,8 @@ line-height: 0; border-radius: 0.65rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border); - color: var(--color-blue); + border: 1px solid var(--c-border); + color: var(--c-primary); } /* Identity, stats, and the "Invite teammates" action all sit on one line; the metric cells render flat (no per-stat box) and are divided by hairlines, so @@ -902,7 +902,7 @@ background: transparent; border: 0; box-shadow: none; - border-left: 1px solid var(--color-border); + border-left: 1px solid var(--c-border); padding: 0 0 0 1.5rem; min-width: 0; gap: 0.3rem; @@ -931,7 +931,7 @@ border-radius: 0; font-size: 0.85rem; font-weight: 400; - color: var(--color-text-3, #64748b); + color: var(--c-text-subtle); } .portal-billing__trial-meter .payg-status__dot, .portal-billing__spend-meter .payg-status__dot { @@ -972,25 +972,25 @@ font-weight: 750; line-height: 1; letter-spacing: -0.02em; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } .portal-billing__bignum-unit { font-size: 0.85rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__segbar { display: flex; height: 0.55rem; border-radius: 999px; overflow: hidden; - background: var(--color-bg-muted, #f1f5f9); + background: var(--c-surface-sunken); } .portal-billing__segbar-seg { height: 100%; } .portal-billing__segbar-seg--blue { - background: var(--color-blue); + background: var(--c-primary); } .portal-billing__segbar-seg--purple { background: var(--color-purple); @@ -1018,7 +1018,7 @@ flex-shrink: 0; } .portal-billing__dot--blue { - background: var(--color-blue); + background: var(--c-primary); } .portal-billing__dot--purple { background: var(--color-purple); @@ -1028,24 +1028,24 @@ } .portal-billing__seglegend-label { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-billing__seglegend-val { - color: var(--color-text-2); + color: var(--c-text-muted); font-variant-numeric: tabular-nums; } .portal-billing__seglegend-desc { - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ── Spend-limit projection line ─────────────────────────────────────── */ .portal-billing__projection { margin: 0.85rem 0 0; font-size: 0.85rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-billing__projection strong { - color: var(--color-amber, #d97706); + color: var(--color-amber, var(--c-warning)); } /* ── Checkout modal: 2-step (spend limit → payment) ──────────────────── */ @@ -1070,15 +1070,15 @@ height: 4px; flex: 1; border-radius: var(--radius-pill); - background: var(--color-border); + background: var(--c-border); } .portal-billing__checkout-steps span.is-done { - background: var(--color-blue); + background: var(--c-primary); } .portal-billing__checkout-stepcount { font-size: 0.75rem; font-weight: 600; - color: var(--color-text-5); + color: var(--c-text-subtle); white-space: nowrap; } @@ -1086,7 +1086,7 @@ margin: 0; font-size: 0.75rem; line-height: 1.55; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-billing__checkout-cap-actions { display: flex; @@ -1101,9 +1101,9 @@ flex-direction: column; gap: 0.5rem; padding: 1rem; - border: 1px solid var(--color-border-input); + border: 1px solid var(--c-border); border-radius: var(--radius-md); - background: var(--color-surface); + background: var(--c-surface); } .portal-billing__card-placeholder-head { display: flex; @@ -1111,14 +1111,14 @@ justify-content: space-between; font-size: 0.75rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-billing__card-placeholder-badge { font-size: 0.625rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-text-5); + color: var(--c-text-subtle); background: var(--color-bg-code); padding: 0.125rem 0.375rem; border-radius: var(--radius-sm); @@ -1129,15 +1129,15 @@ gap: 0.625rem; padding: 0.75rem 0.875rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 0.8125rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-billing__card-placeholder-note { margin: 0; font-size: 0.6875rem; line-height: 1.5; - color: var(--color-text-5); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css index 02f7bfdbf5..13c1f4812f 100644 --- a/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css +++ b/frontend/editor/src/portal/components/policies/ClassificationLabelsSection.css @@ -8,11 +8,11 @@ display: flex; gap: var(--space-3); font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .classification-summary-note { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* Expandable category → labels list. */ @@ -24,10 +24,10 @@ flex-direction: column; } .classification-category { - border-top: 1px solid var(--border-default); + border-top: 1px solid var(--c-border); } .classification-category:last-child { - border-bottom: 1px solid var(--border-default); + border-bottom: 1px solid var(--c-border); } .classification-category-header { min-height: 2.5rem; @@ -43,7 +43,7 @@ } .classification-category-count { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .classification-category-labels { display: flex; diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css index 96113b7aa1..ee0b6dde44 100644 --- a/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css +++ b/frontend/editor/src/portal/components/policies/PolicyCategoryIcon.css @@ -8,8 +8,8 @@ height: 2rem; flex-shrink: 0; border-radius: var(--radius-lg); - background: var(--color-bg-muted); - color: var(--color-text-3); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); /* The shared MUI outline glyph inherits its size from here. */ font-size: 1.15rem; } diff --git a/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx b/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx index 43170a21a4..962cef995a 100644 --- a/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx +++ b/frontend/editor/src/portal/components/processor-flow/FlowSankey.tsx @@ -67,8 +67,8 @@ export function FlowSankey({ sources, outcomes, policies }: FlowSankeyProps) { const y0R = padY + (H - (midH + (outcomes.length - 1) * gap)) / 2; const outFill = (key: FlowOutcomeKey) => OUTCOME_FILL[key]; - const srcFill = "var(--color-blue)"; - const waistFill = "var(--color-text-4)"; + const srcFill = "var(--c-primary)"; + const waistFill = "var(--c-text-subtle)"; const ribbon = ( x0: number, diff --git a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts index 7efbee40e2..65fc6cc9ad 100644 --- a/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts +++ b/frontend/editor/src/portal/components/processor-flow/useFlowParticles.ts @@ -140,7 +140,7 @@ export function useFlowParticles({ ) as SVGCircleElement; c.setAttribute("r", "2.5"); c.setAttribute("opacity", "0.75"); - c.style.fill = "var(--color-blue)"; + c.style.fill = "var(--c-primary)"; pg.appendChild(c); particles.push({ el: c, @@ -219,7 +219,7 @@ export function useFlowParticles({ p.phase = 2; p.t = 0; p.el.style.fill = - OUTCOME_FILL[outcomeKeys[p.out]] ?? "var(--color-blue)"; + OUTCOME_FILL[outcomeKeys[p.out]] ?? "var(--c-primary)"; p.el.setAttribute("r", "2.5"); p.el.setAttribute("opacity", "0.75"); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx index 6c57c501c7..7fb8b865f2 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx @@ -26,7 +26,7 @@ export const Open: Story = { children: (

Ready for payment

-

+

Your quote is accepted. Continue to checkout to pay your committed contract and go live.

diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index 152e1f7a0b..411a3f75ba 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -18,8 +18,8 @@ export interface TierInfo { export const TIER_INFO: Record = { // Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free // manual-tools tier is "Editor plan"; the metered tier is "Processor plan". - free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" }, - pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" }, + free: { labelKey: "portal.tier.free", dotColor: "var(--c-text-subtle)" }, + pro: { labelKey: "portal.tier.pro", dotColor: "var(--c-primary)" }, enterprise: { labelKey: "portal.tier.enterprise", dotColor: "var(--color-purple)", diff --git a/frontend/editor/src/portal/data/Endpoints.stories.tsx b/frontend/editor/src/portal/data/Endpoints.stories.tsx index f702b5dea4..5f3a561766 100644 --- a/frontend/editor/src/portal/data/Endpoints.stories.tsx +++ b/frontend/editor/src/portal/data/Endpoints.stories.tsx @@ -21,7 +21,7 @@ type Story = StoryObj; export const ByVertical: Story = { render: () => (
-
+
{ALL_ENDPOINTS.length} endpoints across {VERTICALS.length} verticals
{VERTICALS.map((v) => ( @@ -42,12 +42,10 @@ export const ByVertical: Story = { background: v.color, }} /> -

+

{v.label}

- + {v.endpoints.length} endpoints
@@ -61,8 +59,8 @@ export const ByVertical: Story = { gap: 12, alignItems: "center", padding: "0.625rem 0.875rem", - background: "var(--color-surface)", - border: "1px solid var(--color-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", borderRadius: "var(--radius-md)", }} > @@ -71,7 +69,7 @@ export const ByVertical: Story = { style={{ fontFamily: "var(--font-mono)", fontSize: 12, - color: "var(--color-text-2)", + color: "var(--c-text-muted)", }} > {e.endpoint} @@ -81,7 +79,7 @@ export const ByVertical: Story = { style={{ fontSize: 13, fontWeight: 500, - color: "var(--color-text-1)", + color: "var(--c-text)", }} > {e.name} @@ -89,7 +87,7 @@ export const ByVertical: Story = {
= { ingest: "var(--color-green)", - validate: "var(--color-blue)", + validate: "var(--c-primary)", modify: "#F97316", secure: "var(--color-red)", store: "var(--color-purple)", @@ -60,12 +60,12 @@ export const PipelineOps: Story = { fontSize: 13, textTransform: "uppercase", letterSpacing: 0.6, - color: "var(--color-text-3)", + color: "var(--c-text-subtle)", }} > {stage} - + {PIPELINE_OPS[stage].length} ops
@@ -109,7 +109,7 @@ export const PipelineOps: Story = { export const LibraryByCategory: Story = { render: () => (
-
+
{LIBRARY_OPS.length} library ops across {OP_CATEGORIES.length}{" "} categories
@@ -137,18 +137,18 @@ export const LibraryByCategory: Story = { style={{ margin: 0, fontSize: 13, - color: "var(--color-text-1)", + color: "var(--c-text)", }} > {cat.name} - + {cat.blurb} @@ -207,8 +207,8 @@ export const Agents: Story = { key={a.id} style={{ padding: 14, - background: "var(--color-surface)", - border: "1px solid var(--color-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", borderRadius: 8, }} > @@ -220,14 +220,14 @@ export const Agents: Story = { marginBottom: 4, }} > - + {a.label}
@@ -241,8 +241,8 @@ export const Agents: Story = { fontSize: 11, padding: "0.125rem 0.375rem", borderRadius: 4, - background: "var(--color-bg-muted)", - color: "var(--color-text-3)", + background: "var(--c-surface-sunken)", + color: "var(--c-text-subtle)", fontFamily: "var(--font-mono)", }} > @@ -267,13 +267,13 @@ export const SourcesAndDestinations: Story = { key={s.id} style={{ padding: "0.625rem 0.75rem", - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", borderRadius: 6, - background: "var(--color-surface)", + background: "var(--c-surface)", }} >
{s.label}
-
+
{s.desc}
@@ -288,13 +288,13 @@ export const SourcesAndDestinations: Story = { key={d.id} style={{ padding: "0.625rem 0.75rem", - border: "1px solid var(--color-border)", + border: "1px solid var(--c-border)", borderRadius: 6, - background: "var(--color-surface)", + background: "var(--c-surface)", }} >
{d.label}
-
+
{d.desc}
diff --git a/frontend/editor/src/portal/data/ops.ts b/frontend/editor/src/portal/data/ops.ts index e5803971ad..f650a13fad 100644 --- a/frontend/editor/src/portal/data/ops.ts +++ b/frontend/editor/src/portal/data/ops.ts @@ -425,7 +425,7 @@ export const OP_CATEGORIES: readonly OpCategoryMeta[] = [ }, { name: "Validation", - color: "var(--color-blue)", + color: "var(--c-primary)", blurb: "Schema checks, trust gates, filters", }, { @@ -470,7 +470,7 @@ export const OP_CATEGORIES: readonly OpCategoryMeta[] = [ }, { name: "Developer Tools", - color: "var(--color-text-5)", + color: "var(--c-text-subtle)", blurb: "Repair, compress, metadata", }, ]; diff --git a/frontend/editor/src/portal/theme/base.css b/frontend/editor/src/portal/theme/base.css index b9fa93d9e6..5326abaffc 100644 --- a/frontend/editor/src/portal/theme/base.css +++ b/frontend/editor/src/portal/theme/base.css @@ -9,7 +9,7 @@ :where, `.portal-scope button` would outweigh `.sui-btn` and strip its fill. */ .portal-scope { - color: var(--color-text-2); + color: var(--c-text-muted); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -31,7 +31,7 @@ } :where(.portal-scope) a { - color: var(--color-blue); + color: var(--c-primary); text-decoration: none; } diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts index 01953245f9..d78f31ed69 100644 --- a/frontend/editor/src/portal/theme/mantineTheme.ts +++ b/frontend/editor/src/portal/theme/mantineTheme.ts @@ -84,25 +84,25 @@ const purple = tuple( */ export const suiCssVariablesResolver: CSSVariablesResolver = () => ({ variables: { - "--mantine-color-text": "var(--color-text-1)", + "--mantine-color-text": "var(--c-text)", "--mantine-color-placeholder": "var(--color-text-placeholder)", - "--mantine-color-body": "var(--color-bg)", + "--mantine-color-body": "var(--c-bg)", }, light: { // Popover/dropdown background + combobox search input - "--mantine-color-white": "var(--color-surface)", + "--mantine-color-white": "var(--c-surface)", // Option hover background - "--mantine-color-gray-0": "var(--color-bg-hover)", + "--mantine-color-gray-0": "var(--c-hover)", // Dropdown border - "--mantine-color-gray-2": "var(--color-border)", + "--mantine-color-gray-2": "var(--c-border)", }, dark: { // Popover/dropdown background (dark-6 is the floating surface in dark mode) - "--mantine-color-dark-6": "var(--color-surface)", + "--mantine-color-dark-6": "var(--c-surface)", // Deeper background used for option hover + combobox search input - "--mantine-color-dark-7": "var(--color-bg)", + "--mantine-color-dark-7": "var(--c-bg)", // Border in dark mode - "--mantine-color-dark-4": "var(--color-border)", + "--mantine-color-dark-4": "var(--c-border)", }, }); diff --git a/frontend/editor/src/portal/views/AccountLink.css b/frontend/editor/src/portal/views/AccountLink.css index 07b49a03f1..34afe4d7af 100644 --- a/frontend/editor/src/portal/views/AccountLink.css +++ b/frontend/editor/src/portal/views/AccountLink.css @@ -19,13 +19,13 @@ margin: 0; font-size: 1.375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-link__page-sub { margin: 0.25rem 0 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); max-width: 44rem; } @@ -49,14 +49,14 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-link__title { margin: 0.125rem 0 0; font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-link__form, @@ -85,13 +85,13 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-link__section-sub { margin: 0.25rem 0 0; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); max-width: 44rem; } @@ -110,16 +110,16 @@ .portal-link__cell-strong { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-link__device-id { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); font-variant-numeric: tabular-nums; } .portal-link__muted { - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.8125rem; } diff --git a/frontend/editor/src/portal/views/DeveloperDocs.css b/frontend/editor/src/portal/views/DeveloperDocs.css index 1786940dd5..d986c1448a 100644 --- a/frontend/editor/src/portal/views/DeveloperDocs.css +++ b/frontend/editor/src/portal/views/DeveloperDocs.css @@ -35,7 +35,7 @@ min-height: 0; height: 100%; overflow-y: auto; - border-right: 1px solid var(--color-border-light); + border-right: 1px solid var(--c-border-subtle); padding: 1.25rem 0; } @@ -61,7 +61,7 @@ top: 50%; transform: translateY(-50%); font-size: 0.9375rem; - color: var(--color-text-4); + color: var(--c-text-subtle); pointer-events: none; } @@ -69,20 +69,20 @@ width: 100%; padding: 0.4rem 0.6rem 0.4rem 1.9rem; font-size: 0.8125rem; - color: var(--color-text-1); + color: var(--c-text); background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); outline: none; } .portal-docs__search-input:focus { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-docs__nav-empty { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); padding: 0.5rem 0.75rem; margin: 0; } @@ -96,7 +96,7 @@ .portal-docs__results-count { font-size: 0.6875rem; font-weight: 500; - color: var(--color-text-4); + color: var(--c-text-subtle); padding: 0 0.75rem 0.5rem; } @@ -110,7 +110,7 @@ /* Hairline divider between results for clear, calm separation. */ .portal-docs__results-list li + li { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-docs__result { @@ -121,7 +121,7 @@ .portal-docs__result:hover, .portal-docs__result.is-active { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-docs__result-body { @@ -145,7 +145,7 @@ .portal-docs__result-title { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); line-height: 1.3; min-width: 0; overflow: hidden; @@ -156,13 +156,13 @@ .portal-docs__result-section { flex-shrink: 0; font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-docs__result-snippet { font-size: 0.75rem; line-height: 1.4; - color: var(--color-text-4); + color: var(--c-text-subtle); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; @@ -171,7 +171,7 @@ /* Subtle match emphasis — coloured text, not a filled block. */ .portal-docs__hl { - color: var(--color-blue); + color: var(--c-primary); font-weight: 600; background: none; } @@ -190,13 +190,13 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Collapsible section header (Overview stays a static label). */ .portal-docs__nav-head--button:hover { - background: var(--color-bg-hover); - color: var(--color-text-2); + background: var(--c-hover); + color: var(--c-text-muted); } .portal-docs__nav-head-main { @@ -214,7 +214,7 @@ .portal-docs__nav-chevron { font-size: 0.625rem; - color: var(--color-text-4); + color: var(--c-text-subtle); transition: transform var(--motion-fast); } @@ -225,7 +225,7 @@ .portal-docs__nav-count { font-size: 0.625rem; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); background: var(--color-bg-subtle); border-radius: var(--radius-pill); padding: 0.05rem 0.4rem; @@ -240,7 +240,7 @@ .portal-docs__nav-children { margin: 0.125rem 0 0.25rem 0.85rem; padding-left: 0.4rem; - border-left: 1px solid var(--color-border-light); + border-left: 1px solid var(--c-border-subtle); display: flex; flex-direction: column; gap: 0.0625rem; @@ -266,7 +266,7 @@ background: transparent; border-radius: 0; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); cursor: pointer; text-align: left; transition: @@ -275,13 +275,13 @@ } .portal-docs__nav-link:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); + background: var(--c-hover); + color: var(--c-text); } .portal-docs__nav-link.is-active { - background: var(--color-blue-light); - color: var(--color-blue); + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); + color: var(--c-primary); font-weight: 600; } @@ -317,7 +317,7 @@ min-height: 0; height: 100%; overflow-y: auto; - border-left: 1px solid var(--color-border-light); + border-left: 1px solid var(--c-border-subtle); padding: 1.5rem 0.75rem; } @@ -331,7 +331,7 @@ font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--color-text-4); + color: var(--c-text-subtle); padding: 0 0.5rem 0.5rem; } @@ -350,7 +350,7 @@ border-left: 2px solid transparent; font-size: 0.8125rem; line-height: 1.35; - color: var(--color-text-3); + color: var(--c-text-subtle); text-decoration: none; transition: color var(--motion-fast); } @@ -361,12 +361,12 @@ } .portal-docs__toc-link:hover { - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__toc-link.is-active { - color: var(--color-blue); - border-left-color: var(--color-blue); + color: var(--c-primary); + border-left-color: var(--c-primary); font-weight: 600; } @@ -392,9 +392,9 @@ padding: 0.5rem 0.75rem; font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); cursor: pointer; } @@ -404,7 +404,7 @@ height: auto; max-height: 60vh; border-right: none; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-docs__sidebar.is-open { @@ -426,7 +426,7 @@ font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.08em; - color: var(--color-blue); + color: var(--c-primary); } .portal-docs__section-title { @@ -434,7 +434,7 @@ font-size: 1.75rem; font-weight: 700; line-height: 1.15; - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__section-lead { @@ -442,13 +442,13 @@ max-width: 46rem; font-size: 0.9375rem; line-height: 1.6; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-docs__callout { font-size: 0.875rem; line-height: 1.55; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-docs__callout code { @@ -483,7 +483,7 @@ width: 1.75rem; height: 1.75rem; border-radius: var(--radius-pill); - background: var(--color-blue); + background: var(--c-primary); color: var(--color-text-on-accent); font-size: 0.8125rem; font-weight: 700; @@ -500,14 +500,14 @@ margin: 0; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__step-body p { margin: 0; font-size: 0.875rem; line-height: 1.55; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-docs__snippet { @@ -529,7 +529,7 @@ align-items: center; gap: 0.75rem; font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ── Rate limits ───────────────────────────────────────────────────────── */ @@ -548,14 +548,14 @@ .portal-docs__limit-label { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-bottom: 0.25rem; } .portal-docs__limit-value { font-size: 1.25rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } /* ── Endpoint reference ────────────────────────────────────────────────── */ @@ -569,7 +569,7 @@ .portal-docs__endpoint-group { display: flex; flex-direction: column; - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-lg); overflow: hidden; } @@ -581,9 +581,9 @@ padding: 0.625rem 0.875rem; font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); background: var(--color-bg-subtle); - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-docs__endpoint-dot { @@ -597,7 +597,7 @@ align-items: center; gap: 0.75rem; padding: 0.5rem 0.875rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); font-size: 0.8125rem; } @@ -608,12 +608,12 @@ .portal-docs__endpoint-path { font-family: var(--font-mono, monospace); font-size: 0.8125rem; - color: var(--color-text-1); + color: var(--c-text); min-width: 11rem; } .portal-docs__endpoint-name { - color: var(--color-text-3); + color: var(--c-text-subtle); flex: 1; min-width: 0; overflow: hidden; @@ -623,7 +623,7 @@ .portal-docs__endpoint-fields { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); white-space: nowrap; } @@ -646,7 +646,7 @@ align-items: center; gap: 0.75rem; font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ── SDK grid ──────────────────────────────────────────────────────────── */ @@ -678,7 +678,7 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); flex: 1; } @@ -715,7 +715,7 @@ margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ── Playbooks ─────────────────────────────────────────────────────────── */ @@ -736,14 +736,14 @@ margin: 0 0 0.375rem; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__playbook-blurb { margin: 0 0 0.875rem; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-docs__playbook-flow { @@ -761,7 +761,7 @@ } .portal-docs__playbook-arrow { - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.75rem; } @@ -808,20 +808,20 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__skill-blurb { margin: 0 0 0.625rem; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-docs__skill-ops { font-family: var(--font-mono, monospace); font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ── Markdown content ──────────────────────────────────────────────────── */ @@ -829,14 +829,14 @@ .portal-docs__md { font-size: 0.9375rem; line-height: 1.65; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-docs__md h1, .portal-docs__md h2, .portal-docs__md h3, .portal-docs__md h4 { - color: var(--color-text-1); + color: var(--c-text); font-weight: 650; line-height: 1.25; margin: 1.75rem 0 0.75rem; @@ -845,7 +845,7 @@ .portal-docs__md h2 { font-size: 1.3125rem; padding-bottom: 0.3rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-docs__md h3 { @@ -871,7 +871,7 @@ } .portal-docs__md a { - color: var(--color-blue); + color: var(--c-primary); text-decoration: none; } @@ -890,10 +890,10 @@ .portal-docs__md blockquote { margin: 0 0 0.875rem; padding: 0.125rem 0.875rem; - border-left: 3px solid var(--color-blue); + border-left: 3px solid var(--c-primary); background: var(--color-bg-subtle); border-radius: 0 var(--radius-md) var(--radius-md) 0; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-docs__md blockquote p { @@ -904,12 +904,12 @@ max-width: 100%; height: auto; border-radius: var(--radius-md); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); } .portal-docs__md hr { border: none; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); margin: 1.5rem 0; } @@ -922,7 +922,7 @@ margin: 0; padding: 0.875rem 4rem 0.875rem 0.875rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); overflow-x: auto; font-size: 0.8125rem; @@ -954,7 +954,7 @@ .portal-docs__md-tablewrap th, .portal-docs__md-tablewrap td { - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); padding: 0.4rem 0.625rem; text-align: left; } @@ -967,16 +967,16 @@ .portal-docs__source { margin-top: 2rem; padding-top: 1rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); font-size: 0.8125rem; } .portal-docs__source a { - color: var(--color-text-3); + color: var(--c-text-subtle); text-decoration: none; } .portal-docs__source a:hover { - color: var(--color-blue); + color: var(--c-primary); text-decoration: underline; } diff --git a/frontend/editor/src/portal/views/Documents.css b/frontend/editor/src/portal/views/Documents.css index 13cbf33c3c..f363037981 100644 --- a/frontend/editor/src/portal/views/Documents.css +++ b/frontend/editor/src/portal/views/Documents.css @@ -25,14 +25,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-documents__sub { margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 46rem; } @@ -81,12 +81,12 @@ .portal-documents__name { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-documents__note { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-documents__lock { @@ -97,18 +97,18 @@ .portal-documents__muted { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-documents__action { font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-documents__editor-action { font-size: 0.8125rem; font-style: italic; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Drawer body */ @@ -137,7 +137,7 @@ .portal-documents__field { font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } /* Masked / sensitive placeholder */ @@ -149,7 +149,7 @@ padding: 2rem 1rem; text-align: center; background: var(--color-bg-subtle); - border: 1px dashed var(--color-border); + border: 1px dashed var(--c-border); border-radius: var(--radius-lg); } @@ -160,7 +160,7 @@ .portal-documents__masked-text { margin: 0; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 22rem; } @@ -192,7 +192,7 @@ height: 0.5rem; margin-top: 0.375rem; border-radius: 50%; - background: var(--color-blue); + background: var(--c-primary); } /* Connecting line between successive timeline dots. */ @@ -205,7 +205,7 @@ transform: translateX(-50%); width: 1px; height: calc(100% + 1rem); - background: var(--color-border); + background: var(--c-border); } .portal-documents__timeline-body { @@ -223,18 +223,18 @@ .portal-documents__timeline-time { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-documents__timeline-detail { margin: 0; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); line-height: 1.45; } .portal-documents__timeline-actor { font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/portal/views/EditorAdmin.css b/frontend/editor/src/portal/views/EditorAdmin.css index 61ef2834dd..1ccd0d63b1 100644 --- a/frontend/editor/src/portal/views/EditorAdmin.css +++ b/frontend/editor/src/portal/views/EditorAdmin.css @@ -19,14 +19,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__sub { margin: 0.25rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 52rem; } @@ -39,14 +39,14 @@ margin: 0; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__section-sub { margin: 0.1875rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-4); + color: var(--c-text-subtle); max-width: 52rem; } @@ -92,11 +92,11 @@ .portal-editor__target-icon--neutral { background: var(--color-bg-subtle); - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-editor__target-icon--blue { - background: var(--color-blue-light); - color: var(--color-blue); + background: var(--c-primary-tint); + color: var(--c-primary); } .portal-editor__target-icon--purple { background: var(--color-purple-light); @@ -112,21 +112,21 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__target-tagline { margin: 0.1875rem 0 0; font-size: 0.75rem; line-height: 1.45; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-editor__target-meta { margin: 0; font-size: 0.75rem; font-family: var(--font-mono); - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ──────────────────────────────────────────────────────────────────────── */ @@ -140,7 +140,7 @@ gap: 0.625rem; padding: 0.875rem; background: var(--color-bg-subtle); - border: 1px dashed var(--color-border); + border: 1px dashed var(--c-border); border-radius: var(--radius-md); } @@ -148,7 +148,7 @@ margin: 0; font-size: 0.8125rem; line-height: 1.45; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* ──────────────────────────────────────────────────────────────────────── */ @@ -191,8 +191,8 @@ } .portal-editor__pairing-icon--blue { - background: var(--color-blue-light); - color: var(--color-blue); + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); + color: var(--c-primary); } .portal-editor__pairing-icon--purple { background: var(--color-purple-light); @@ -212,27 +212,27 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__pairing-desc { margin: 0.1875rem 0 0; font-size: 0.75rem; line-height: 1.45; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-editor__pairing-value { padding: 0.625rem 0.75rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); } .portal-editor__pairing-value code { font-family: var(--font-mono); font-size: 0.8125rem; - color: var(--color-text-1); + color: var(--c-text); word-break: break-all; } @@ -258,7 +258,7 @@ .portal-editor__cell-strong { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__cell-muted { @@ -268,12 +268,12 @@ .portal-editor__mono { font-family: var(--font-mono); font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-editor__muted { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ──────────────────────────────────────────────────────────────────────── */ @@ -313,14 +313,14 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-editor__panel-sub { margin: 0.25rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-editor__enterprise-tag { diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 3027dfa1bc..732bd617a5 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -16,12 +16,12 @@ font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-home__greeting-date { margin: 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Two-column dashboard row (activity + quick actions) */ @@ -47,12 +47,12 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-home__quick-sub { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-home__quick-list { @@ -65,7 +65,7 @@ shared Button's leftSection / label / rightSection, not overridden here. */ .portal-home__quick-row { background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); text-align: left; transition: @@ -74,8 +74,8 @@ } .portal-home__quick-row:hover { - background: var(--color-bg-hover); - border-color: var(--color-border); + background: var(--c-hover); + border-color: var(--c-border); } /* Let the label grow so the arrow (rightSection) is pushed to the far right, @@ -109,18 +109,19 @@ .portal-home__quick-text strong { font-size: 0.8125rem; - color: var(--color-text-1); + color: var(--c-text); font-weight: 600; } .portal-home__quick-text span { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-home__quick-arrow { display: inline-flex; - color: var(--color-text-5); + font-size: 0.875rem; + color: var(--c-text-subtle); } .portal-home__quick-arrow svg { width: 1rem; @@ -128,5 +129,5 @@ } .portal-home__quick-row:hover .portal-home__quick-arrow { - color: var(--color-blue); + color: var(--c-primary); } diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index 1d98dee925..2bff218e96 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -24,13 +24,13 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__sub { margin: 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); max-width: 48rem; } @@ -62,13 +62,13 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__section-sub { margin: 0.125rem 0 0; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Audit tab header row: section heading + Export action */ @@ -95,7 +95,7 @@ .portal-infra__export-label { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__export-fields { @@ -113,7 +113,7 @@ .portal-infra__export-error { margin: 0; font-size: 0.8125rem; - color: var(--color-danger, #e5484d); + color: var(--c-danger); } .portal-infra__export-actions { @@ -146,13 +146,13 @@ .portal-infra__cell-strong { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__cell-code { font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); background: var(--color-bg-code); padding: 0.0625rem 0.3125rem; border-radius: var(--radius-sm); @@ -162,12 +162,12 @@ .portal-infra__mono { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-infra__muted { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Region load cell */ @@ -180,7 +180,7 @@ .portal-infra__load-pct { font-family: var(--font-mono); font-size: 0.6875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); min-width: 2.25rem; text-align: right; } @@ -226,7 +226,7 @@ } .portal-infra__key-head:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-infra__key-id { @@ -244,7 +244,7 @@ .portal-infra__chevron { font-size: 1.125rem; - color: var(--color-text-5); + color: var(--c-text-subtle); transition: transform var(--motion-fast); line-height: 1; } @@ -255,7 +255,7 @@ .portal-infra__key-body { padding: 0 1.125rem 1.125rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-infra__kv { @@ -279,14 +279,14 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); margin-bottom: 0.25rem; } .portal-infra__kv dd { margin: 0; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-infra__chips { @@ -342,7 +342,7 @@ .portal-infra__cert-detail { margin: 0; font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.4; } @@ -392,7 +392,7 @@ .portal-infra__attestation-link { font-size: 0.75rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); text-decoration: none; } @@ -412,7 +412,7 @@ .portal-infra__usage-value { font-size: 1.25rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__usage-value .portal-infra__muted { @@ -436,7 +436,7 @@ gap: 0.75rem; padding: 0.625rem 0.75rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); } @@ -447,8 +447,8 @@ width: 1.75rem; height: 1.75rem; border-radius: var(--radius-md); - background: var(--color-blue-light); - color: var(--color-blue); + background: var(--c-primary-tint); + color: var(--c-primary); font-size: 0.875rem; } @@ -480,7 +480,7 @@ gap: 0.25rem; padding: 0.625rem 0.75rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); } @@ -493,7 +493,7 @@ width: 0.5rem; height: 0.5rem; border-radius: 50%; - background: var(--color-text-5); + background: var(--c-text-subtle); } .portal-infra__lifecycle-stage.is-active .portal-infra__lifecycle-dot { @@ -503,13 +503,13 @@ .portal-infra__lifecycle-label { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-infra__lifecycle-arrow { display: flex; align-items: center; - color: var(--color-text-5); + color: var(--c-text-subtle); font-size: 0.875rem; } @@ -522,7 +522,7 @@ .portal-infra__event span { font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } /* ── Table skeleton ─────────────────────────────────────────────────────── */ diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index c7e273eec1..b56b199676 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -20,7 +20,7 @@ gap: 0.75rem; flex-wrap: wrap; padding-bottom: 1rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-builder__back { @@ -31,13 +31,13 @@ background: none; padding: 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); cursor: pointer; white-space: nowrap; } .portal-builder__back:hover { - color: var(--color-text-1); + color: var(--c-text); } .portal-builder__head-main { @@ -75,13 +75,13 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } .portal-builder__empty { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin: 0; padding: 0.5rem 0; } @@ -100,8 +100,8 @@ display: flex; align-items: center; gap: 0.5rem; - background: var(--color-surface); - border: 1px solid var(--color-border-light); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-lg); padding: 0.5rem 0.625rem; transition: @@ -110,8 +110,8 @@ } .portal-builder__step--active { - border-color: var(--color-blue); - background: var(--color-blue-light); + border-color: var(--c-primary); + background: var(--c-primary-tint); } .portal-builder__step-main { @@ -138,12 +138,12 @@ border-radius: 50%; font-size: 0.6875rem; font-weight: 600; - background: var(--color-blue-light); - color: var(--color-blue); + background: var(--c-primary-tint); + color: var(--c-primary); } .portal-builder__step--active .portal-builder__step-index { - background: var(--color-blue); + background: var(--c-primary); color: #fff; } @@ -156,12 +156,12 @@ .portal-builder__step-name { font-size: 0.875rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } .portal-builder__step-note { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-builder__step-actions { @@ -177,9 +177,9 @@ height: 1.5rem; padding: 0; border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-3); + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-subtle); cursor: pointer; transition: background var(--motion-fast), @@ -187,8 +187,8 @@ } .portal-builder__step-actions button:hover:not(:disabled) { - background: var(--color-bg-hover); - color: var(--color-text-1); + background: var(--c-hover); + color: var(--c-text); } .portal-builder__step-actions button:disabled { @@ -203,10 +203,10 @@ gap: 0.375rem; width: 100%; padding: 0.625rem; - border: 1px dashed var(--color-border); + border: 1px dashed var(--c-border); border-radius: var(--radius-lg); background: none; - color: var(--color-text-3); + color: var(--c-text-subtle); font-size: 0.8125rem; cursor: pointer; transition: @@ -215,8 +215,8 @@ } .portal-builder__add-step:hover { - border-color: var(--color-blue); - color: var(--color-blue); + border-color: var(--c-primary); + color: var(--c-primary); } /* Pipeline settings (above the operation list) */ @@ -225,7 +225,7 @@ flex-direction: column; gap: 0.75rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-lg); padding: 1.125rem; } @@ -259,8 +259,8 @@ } .portal-builder__inspector { - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: 1.125rem; @@ -268,9 +268,9 @@ /* Tool picker */ .portal-pipelines__picker { - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); - background: var(--color-surface); + background: var(--c-surface); overflow: hidden; } @@ -278,7 +278,7 @@ display: flex; align-items: center; padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-pipelines__picker-search input { @@ -287,7 +287,7 @@ background: none; padding: 0; font-size: 0.875rem; - color: var(--color-text-1); + color: var(--c-text); outline: none; } @@ -300,7 +300,7 @@ .portal-pipelines__picker-group-label { padding: 0.5rem 0.75rem 0.25rem; font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-pipelines__picker-item { @@ -313,18 +313,18 @@ padding: 0.4375rem 0.75rem; text-align: left; cursor: pointer; - color: var(--color-text-1); + color: var(--c-text); transition: background var(--motion-fast); } .portal-pipelines__picker-item:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-pipelines__picker-icon { display: inline-flex; align-items: center; - color: var(--color-text-3); + color: var(--c-text-subtle); font-size: 1.125rem; } @@ -335,7 +335,7 @@ .portal-pipelines__picker-empty { padding: 1rem 0.75rem; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin: 0; } @@ -349,11 +349,11 @@ padding: 0; font-weight: 400; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-builder__back.sui-btn:hover { - color: var(--color-text-1); + color: var(--c-text); } .portal-builder__step-main.sui-btn { @@ -369,16 +369,16 @@ height: auto; min-height: 0; padding: 0.625rem; - border: 1px dashed var(--color-border); + border: 1px dashed var(--c-border); background: none; font-weight: 400; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-builder__add-step.sui-btn:hover { - border-color: var(--color-blue); - color: var(--color-blue); + border-color: var(--c-primary); + color: var(--c-primary); } .portal-pipelines__picker-item.sui-btn { @@ -386,11 +386,11 @@ min-height: 0; padding: 0.4375rem 0.75rem; font-weight: 400; - color: var(--color-text-1); + color: var(--c-text); } .portal-pipelines__picker-item.sui-btn:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-builder__step-actions .sui-ai { diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index 5c99f6a224..989feab4fe 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -20,14 +20,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-pipelines__sub { margin: 0.25rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 46rem; } @@ -49,7 +49,7 @@ .portal-pipelines__name-text strong { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-pipelines__pipe-dot { @@ -61,25 +61,25 @@ flex-shrink: 0; border-radius: var(--radius-md); font-size: 0.875rem; - background: var(--color-blue-light); - color: var(--color-blue); + background: var(--c-primary-tint); + color: var(--c-primary); } .portal-pipelines__muted { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin: 0; } .portal-pipelines__caret { display: inline-block; - color: var(--color-text-5); + color: var(--c-text-subtle); transition: transform var(--motion-fast); } .portal-pipelines__caret.is-open { transform: rotate(90deg); - color: var(--color-blue); + color: var(--c-primary); } /* Table skeleton */ @@ -94,7 +94,7 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } @@ -113,7 +113,7 @@ overflow-y: auto; padding: 0.625rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); } diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index e58d5522a8..e1a821ee27 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -18,14 +18,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-policies__sub { margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 46rem; } @@ -49,14 +49,14 @@ flex: 1; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* "Set up" underline link inside the active-row button's rightSection */ .portal-policies__setup-link { font-size: 0.8125rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); text-decoration: underline; text-underline-offset: 2px; } @@ -83,7 +83,7 @@ .portal-policies__card-enforces { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -106,13 +106,13 @@ .portal-policies__card-statval { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); line-height: 1.2; } .portal-policies__card-statlbl { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.2; } @@ -124,19 +124,19 @@ height: 2rem; flex-shrink: 0; font-size: 1.125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-policies__card-title { margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-policies__card-blurb { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.4; } @@ -144,7 +144,7 @@ margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-policies__card-foot { @@ -165,7 +165,7 @@ .portal-policies__card-cta { font-size: 0.75rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); } .portal-policies__chevron-right { @@ -180,7 +180,7 @@ gap: 0.5rem; margin-top: auto; padding-top: 0.75rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } @media (max-width: 30rem) { @@ -219,7 +219,7 @@ margin: 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-policies__wizard-heading { @@ -227,7 +227,7 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } @@ -260,13 +260,13 @@ } .portal-policies__capability + .portal-policies__capability { - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); } /* An enabled row with config gets a faint tint so the revealed settings read as * belonging to it. */ .portal-policies__capability[data-on] .portal-policies__capability-config { - border-top: 1px dashed var(--color-border); + border-top: 1px dashed var(--c-border); padding-top: 0.75rem; margin-top: 0.75rem; } @@ -282,7 +282,7 @@ } .portal-policies__source--on { - border-color: var(--color-blue); + border-color: var(--c-primary); } .portal-policies__source-label { @@ -297,7 +297,7 @@ padding: 0; font-size: 0.75rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); cursor: pointer; } @@ -320,17 +320,17 @@ } .portal-policies__detail-sep { - color: var(--color-text-5); + color: var(--c-text-subtle); font-size: 0.75rem; } .portal-policies__detail-meta { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-policies__enforce-arrow { - color: var(--color-text-5); + color: var(--c-text-subtle); font-size: 0.75rem; } @@ -344,7 +344,7 @@ .portal-policies__detail-inline-label { font-size: 0.6875rem; font-weight: 600; - color: var(--color-text-4); + color: var(--c-text-subtle); text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0; @@ -353,7 +353,7 @@ .portal-policies__detail-inline-value { font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); line-height: 1.5; } @@ -366,7 +366,7 @@ } .portal-policies__activity-row + .portal-policies__activity-row { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-policies__activity-icon { @@ -384,7 +384,7 @@ color: var(--color-amber); } .portal-policies__activity-icon--info { - color: var(--color-blue); + color: var(--c-primary); } @keyframes portal-policies-spin { @@ -408,17 +408,17 @@ .portal-policies__activity-doc { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-policies__activity-action { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-policies__activity-time { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); white-space: nowrap; } @@ -457,7 +457,7 @@ } .portal-policies__detail-stats .sui-stat + .sui-stat { - border-left: 1px solid var(--color-border-light); + border-left: 1px solid var(--c-border-subtle); } /* ── Catalogue table (replaces the stacked category cards) ──────────────── */ @@ -471,7 +471,7 @@ .portal-policies__cell-name { font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); min-width: 0; } @@ -483,13 +483,13 @@ .portal-policies__muted { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-policies__docs { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } /* The .pcat-badge icon styles live with the PolicyCategoryIcon component diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index f733b2c5a3..e26fd74a31 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -19,13 +19,13 @@ margin: 0; font-size: 1.375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__subtitle { margin: 0.25rem 0 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Eyebrow label shared by the journey header + SE block */ @@ -35,7 +35,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-5); + color: var(--c-text-subtle); margin-bottom: 0.25rem; } @@ -47,7 +47,7 @@ justify-content: space-between; gap: 1rem; padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); flex-wrap: wrap; } @@ -55,7 +55,7 @@ margin: 0; font-size: 1.0625rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__journey-sub { @@ -63,7 +63,7 @@ max-width: 36rem; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-proc__se { @@ -80,12 +80,12 @@ .portal-proc__se-name { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__se-email { font-size: 0.75rem; - color: var(--color-blue); + color: var(--c-primary); text-decoration: none; } @@ -96,7 +96,7 @@ /* Stepper band */ .portal-proc__journey-stepper { padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-proc__steps { @@ -122,7 +122,7 @@ width: 0.875rem; height: 0.875rem; border-radius: 50%; - background: var(--color-border-input); + background: var(--c-border); } .portal-proc__step--complete .portal-proc__step-dot { @@ -139,16 +139,16 @@ font-weight: 500; text-align: center; white-space: nowrap; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__step--complete .portal-proc__step-label { - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-proc__step--current .portal-proc__step-label { font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } /* Connector aligns with the 0.875rem dots: (14px − 2px) / 2 = 6px down */ @@ -156,7 +156,7 @@ flex: 1; height: 2px; margin: 0.375rem 0.375rem 0; - background: var(--color-border-light); + background: var(--c-border-subtle); } .portal-proc__step-line[data-filled="true"] { @@ -170,28 +170,28 @@ gap: 0.625rem; flex-wrap: wrap; padding: 0.75rem 1.5rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); background: var(--color-bg-code); } .portal-proc__trial-title { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__trial-dim { font-size: 0.78125rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__trial-key { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 0.6875rem; - color: var(--color-text-4); - background: var(--color-surface); - border: 1px solid var(--color-border-input); + color: var(--c-text-subtle); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: 0.375rem; padding: 0.1875rem 0.5rem; } @@ -212,7 +212,7 @@ gap: 0.625rem; font-size: 0.84375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__next-dot { @@ -230,20 +230,20 @@ /* ── Documents card ───────────────────────────────────────────────────── */ .portal-proc__docs-head { padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-proc__docs-title { margin: 0; font-size: 0.9375rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__docs-sub { margin: 0.25rem 0 0; font-size: 0.78125rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__docs-body { @@ -273,7 +273,7 @@ } .portal-proc__stage-dot[data-state="upcoming"] { - background: var(--color-border-input); + background: var(--c-border); } .portal-proc__stage-label { @@ -281,30 +281,30 @@ font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-proc__stage-label[data-current] { - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__stage-hint { font-size: 0.71875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__stage-count { font-size: 0.6875rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } /* Document lists inside the accordion */ .portal-proc__doc-list { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-proc__doc-list--boxed { - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: 0.5rem; overflow: hidden; } @@ -326,20 +326,20 @@ font-size: 0.71875rem; font-weight: 400; line-height: 1.45; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__acc-toggle-label { font-size: 0.75rem; font-weight: 600; - color: var(--color-blue); + color: var(--c-primary); } .portal-proc__supporting-groups { display: flex; flex-direction: column; gap: 1rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); padding: 0.875rem; } @@ -348,7 +348,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-5); + color: var(--c-text-subtle); margin-bottom: 0.5rem; } @@ -359,7 +359,7 @@ justify-content: space-between; gap: 1rem; padding: 0.75rem 0.875rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-proc__doc:last-child { @@ -384,14 +384,14 @@ .portal-proc__doc-name { font-size: 0.84375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__doc-sub { margin: 0.0625rem 0 0; font-size: 0.71875rem; line-height: 1.4; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__doc-actions { @@ -413,7 +413,7 @@ margin: 0 0 1rem; font-size: 0.875rem; line-height: 1.55; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-proc__modal-actions { @@ -435,7 +435,7 @@ .portal-proc__upload-name { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } @media (max-width: 48rem) { @@ -466,8 +466,8 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-primary, #2383e2); - background: var(--color-primary-light, #eaf2fb); + color: var(--c-primary); + background: var(--c-primary-subtle); padding: 0.15rem 0.5rem; border-radius: 0.375rem; margin-bottom: 0.4rem; @@ -476,11 +476,11 @@ margin: 0; font-size: 0.8125rem; line-height: 1.45; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 44rem; } .portal-proc__upsell-copy strong { - color: var(--color-text-1); + color: var(--c-text); } /* ── Quote builder ────────────────────────────────────────────────────────── */ @@ -494,11 +494,11 @@ margin: 0; font-size: 1rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__builder-step { font-size: 0.75rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__builder-body { display: flex; @@ -510,23 +510,23 @@ flex-direction: column; gap: 0.3rem; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-proc__field input, .portal-proc__field select { padding: 0.45rem 0.6rem; - border: 1px solid var(--color-border, #eae8e3); + border: 1px solid var(--c-border); border-radius: 0.5rem; font-size: 0.875rem; - background: var(--color-bg-elevated, #fff); - color: var(--color-text-1); + background: var(--c-input-bg); + color: var(--c-text); } .portal-proc__builder-addons { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-proc__builder-addons label { display: flex; @@ -543,16 +543,16 @@ display: flex; align-items: baseline; justify-content: space-between; - border-bottom: 1px solid var(--color-border, #eae8e3); + border-bottom: 1px solid var(--c-border); padding-bottom: 0.5rem; } .portal-proc__quote-number { font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__quote-valid { font-size: 0.75rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-proc__quote-lines { list-style: none; @@ -564,11 +564,11 @@ justify-content: space-between; padding: 0.4rem 0; font-size: 0.8125rem; - color: var(--color-text-2); - border-bottom: 1px solid var(--color-border-light, #f0eee9); + color: var(--c-text-muted); + border-bottom: 1px solid var(--c-border-subtle); } .portal-proc__quote-lines li[data-kind="DISCOUNT"] { - color: var(--color-success, #0f7b6c); + color: var(--c-success); } .portal-proc__quote-total { display: flex; @@ -579,19 +579,19 @@ } .portal-proc__quote-total strong { font-size: 1.25rem; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__quote-tcv { font-size: 0.75rem; - color: var(--color-text-5); + color: var(--c-text-subtle); } /* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */ .portal-qb { - background: #ffffff; - border: 1px solid #e3e1dc; + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: 14px; - box-shadow: inset 0 0 0 1px #eae8e3; + box-shadow: inset 0 0 0 1px var(--c-border-subtle); overflow: hidden; } .portal-qb__head { @@ -599,10 +599,10 @@ align-items: center; justify-content: space-between; padding: 18px 24px 14px; - border-bottom: 1px solid #f0eee9; + border-bottom: 1px solid var(--c-border-subtle); background: linear-gradient( 180deg, - rgba(35, 131, 226, 0.055) 0%, + color-mix(in srgb, var(--c-primary) 5.5%, transparent) 0%, transparent 100% ); } @@ -610,13 +610,13 @@ margin: 0; font-size: 16px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__stepchip { font-size: 11px; font-weight: 700; - color: #9b9a97; - background: #f5f4f1; + color: var(--c-text-subtle); + background: var(--c-surface-sunken); padding: 3px 10px; border-radius: 999px; } @@ -629,11 +629,11 @@ flex: 1; height: 6px; border-radius: 999px; - background: #f0eee9; + background: var(--c-surface-sunken); transition: background 0.3s; } .portal-qb__progress span[data-on] { - background: #2383e2; + background: var(--c-primary); } .portal-qb__body { padding: 20px 24px; @@ -650,7 +650,7 @@ width: 38px; height: 38px; border-radius: 10px; - background: #eaf2fb; + background: var(--c-primary-tint); display: flex; align-items: center; justify-content: center; @@ -660,11 +660,11 @@ .portal-qb__intro-title { font-size: 15px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__intro-sub { font-size: 12.5px; - color: #9b9a97; + color: var(--c-text-subtle); margin-top: 1px; } .portal-qb__field { @@ -675,7 +675,7 @@ display: block; font-size: 12px; font-weight: 600; - color: #787774; + color: var(--c-text-muted); margin-bottom: 6px; } .portal-qb__field input, @@ -685,11 +685,11 @@ padding: 9px 11px; font-size: 13.5px; border-radius: 8px; - border: 1px solid #e3e1dc; + border: 1px solid var(--c-border); font-family: inherit; outline: none; - background: #fff; - color: #37352f; + background: var(--c-input-bg); + color: var(--c-text); } .portal-qb__row { display: flex; @@ -703,7 +703,7 @@ .portal-qb__hint { margin: 7px 0 0; font-size: 11.5px; - color: #9b9a97; + color: var(--c-text-subtle); line-height: 1.4; } .portal-qb__pills { @@ -716,20 +716,20 @@ font-size: 13px; font-weight: 600; border-radius: 8px; - border: 1px solid #e3e1dc; - background: #fff; - color: #37352f; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text); cursor: pointer; } .portal-qb__pills button[data-on] { - background: #2383e2; - border-color: #2383e2; - color: #fff; + background: var(--c-primary); + border-color: var(--c-primary); + color: var(--c-text-on-primary); } .portal-qb__discount { margin: 6px 0 0; font-size: 11.5px; - color: #0f7b6c; + color: var(--c-success); } .portal-qb__opts { display: flex; @@ -742,28 +742,28 @@ min-width: 150px; padding: 12px 14px; border-radius: 9px; - border: 1px solid #e3e1dc; - background: #fff; + border: 1px solid var(--c-border); + background: var(--c-surface); cursor: pointer; display: flex; flex-direction: column; gap: 2px; } .portal-qb__opt[data-on] { - border-color: #2383e2; - background: #eaf2fb; + border-color: var(--c-primary); + background: var(--c-primary-tint); } .portal-qb__opt-title { font-size: 13px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__opt[data-on] .portal-qb__opt-title { - color: #1b6ec2; + color: var(--c-primary); } .portal-qb__opt-sub { font-size: 11.5px; - color: #9b9a97; + color: var(--c-text-muted); line-height: 1.4; } .portal-qb__addons { @@ -777,43 +777,43 @@ gap: 11px; padding: 11px 13px; border-radius: 9px; - border: 1px solid #e3e1dc; - background: #fff; + border: 1px solid var(--c-border); + background: var(--c-surface); cursor: pointer; text-align: left; } .portal-qb__addon[data-on] { - border-color: #2383e2; - background: #eaf2fb; + border-color: var(--c-primary); + background: var(--c-primary-tint); } .portal-qb__addon-box { width: 18px; height: 18px; flex-shrink: 0; border-radius: 5px; - border: 1px solid #d3d1cb; + border: 1px solid var(--c-border); display: flex; align-items: center; justify-content: center; font-size: 12px; color: transparent; - background: #fff; + background: var(--c-surface); } .portal-qb__addon[data-on] .portal-qb__addon-box { - background: #2383e2; - border-color: #2383e2; - color: #fff; + background: var(--c-primary); + border-color: var(--c-primary); + color: var(--c-text-on-primary); } .portal-qb__addon-title { display: block; font-size: 13px; font-weight: 600; - color: #37352f; + color: var(--c-text); } .portal-qb__addon-sub { display: block; font-size: 11.5px; - color: #9b9a97; + color: var(--c-text-subtle); margin-top: 1px; } .portal-qb__eula { @@ -822,10 +822,10 @@ gap: 10px; padding: 12px; border-radius: 9px; - border: 1px solid #f0eee9; - background: #f5f4f1; + border: 1px solid var(--c-border-subtle); + background: var(--c-surface-sunken); font-size: 12.5px; - color: #37352f; + color: var(--c-text); line-height: 1.5; cursor: pointer; } @@ -835,12 +835,12 @@ justify-content: space-between; gap: 12px; padding: 14px 24px; - border-top: 1px solid #f0eee9; + border-top: 1px solid var(--c-border-subtle); flex-wrap: wrap; } .portal-qb__running { font-size: 11.5px; - color: #9b9a97; + color: var(--c-text-subtle); } .portal-qb__foot-btns { display: flex; @@ -848,34 +848,34 @@ } /* Step 4 — the itemised quote paper */ .portal-qb__papertray { - background: #f5f4f1; + background: var(--c-surface-sunken); padding: 18px; max-height: 56vh; overflow-y: auto; margin: -20px -24px; } .portal-qb__paper { - background: #fff; - border: 1px solid #f0eee9; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 12px; overflow: hidden; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .portal-qb__paper-head { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px 24px; - border-bottom: 1px solid #f0eee9; + border-bottom: 1px solid var(--c-border-subtle); } .portal-qb__paper-brand { font-size: 13.5px; font-weight: 800; - color: #37352f; + color: var(--c-text); } .portal-qb__paper-eyebrow { font-size: 11px; - color: #9b9a97; + color: var(--c-text-subtle); } .portal-qb__paper-meta { text-align: right; @@ -883,11 +883,11 @@ .portal-qb__quote-number { font-size: 12.5px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__paper-meta div:not(.portal-qb__quote-number) { font-size: 11px; - color: #9b9a97; + color: var(--c-text-subtle); } .portal-qb__paper-for { padding: 18px 24px 0; @@ -895,7 +895,7 @@ .portal-qb__paper-company { font-size: 14px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__lines { list-style: none; @@ -907,16 +907,16 @@ justify-content: space-between; gap: 14px; padding: 10px 0; - border-bottom: 1px solid #f0eee9; + border-bottom: 1px solid var(--c-border-subtle); font-size: 13px; font-weight: 600; - color: #37352f; + color: var(--c-text); } .portal-qb__lines li[data-kind="DISCOUNT"] { - color: #0f7b6c; + color: var(--c-success); } .portal-qb__lines li[data-kind="INCLUDED"] span:last-child { - color: #9b9a97; + color: var(--c-text-subtle); font-weight: 500; } .portal-qb__total { @@ -926,17 +926,17 @@ margin: 16px 24px 24px; padding: 16px 18px; border-radius: 10px; - background: #eaf2fb; - border: 1px solid #b8d5f2; + background: var(--c-primary-tint); + border: 1px solid var(--c-primary-border); } .portal-qb__total-label { font-size: 13px; font-weight: 700; - color: #37352f; + color: var(--c-text); } .portal-qb__total-tcv { font-size: 11.5px; - color: #787774; + color: var(--c-text-muted); margin-top: 2px; } .portal-qb__total-num { @@ -946,12 +946,12 @@ display: block; font-size: 23px; font-weight: 800; - color: #37352f; + color: var(--c-text); line-height: 1; } .portal-qb__total-num span { font-size: 11px; - color: #9b9a97; + color: var(--c-text-subtle); margin-top: 3px; } @@ -962,16 +962,16 @@ /* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */ .portal-hero { - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: 12px; padding: 1.1rem 1.25rem; background: radial-gradient( 120% 140% at 100% 0%, - rgba(124, 58, 237, 0.08), + color-mix(in srgb, var(--c-hue-violet) 8%, transparent), transparent 55% ), - var(--color-surface); + var(--c-surface); display: flex; flex-direction: column; gap: 1rem; @@ -989,13 +989,13 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--color-primary, #2383e2); + color: var(--c-primary); } .portal-hero__company { display: block; font-size: 1.0625rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); margin-top: 0.2rem; } .portal-hero__chips { @@ -1006,8 +1006,8 @@ .portal-hero__chip { font-size: 0.6875rem; font-weight: 600; - color: var(--color-text-3); - background: var(--color-border-light); + color: var(--c-text-subtle); + background: var(--c-border-subtle); border-radius: 999px; padding: 0.2rem 0.6rem; } @@ -1021,7 +1021,7 @@ gap: 0.75rem; flex-wrap: wrap; padding-top: 0.85rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-hero__next-label { display: inline-flex; @@ -1029,14 +1029,14 @@ gap: 0.45rem; font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-hero__next-dot { width: 0.5rem; height: 0.5rem; border-radius: 999px; - background: var(--color-primary, #2383e2); - box-shadow: 0 0 0 3px rgba(35, 131, 226, 0.18); + background: var(--c-primary); + box-shadow: 0 0 0 3px var(--c-primary-subtle); } /* ── Full-screen takeover modal (procurement flow) ────────────────────────── */ @@ -1049,7 +1049,7 @@ justify-content: center; padding: clamp(0.5rem, 4vh, 3rem) 1rem; overflow-y: auto; - background: rgba(15, 23, 42, 0.55); + background: var(--c-overlay); backdrop-filter: blur(6px) saturate(160%); -webkit-backdrop-filter: blur(6px) saturate(160%); animation: portal-procmodal-fade 0.15s ease-out; @@ -1066,10 +1066,10 @@ position: relative; width: 100%; max-width: 62rem; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: 14px; - box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); padding: 1.5rem 1.5rem 1.75rem; } .portal-procmodal__close { @@ -1077,8 +1077,8 @@ top: 0.85rem; right: 0.85rem; border: none; - background: var(--color-border-light); - color: var(--color-text-3); + background: var(--c-border-subtle); + color: var(--c-text-subtle); width: 1.9rem; height: 1.9rem; border-radius: 8px; @@ -1086,8 +1086,8 @@ cursor: pointer; } .portal-procmodal__close:hover { - background: var(--color-border); - color: var(--color-text-1); + background: var(--c-border); + color: var(--c-text); } .portal-procmodal__header { margin-bottom: 1.25rem; @@ -1097,12 +1097,12 @@ margin: 0; font-size: 1.35rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-procmodal__sub { margin: 0.3rem 0 0; font-size: 0.875rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-procmodal__body { display: flex; @@ -1122,7 +1122,7 @@ .portal-proc__license { margin-top: 1.25rem; padding: 1rem; - border: 1px solid var(--color-border, rgba(0, 0, 0, 0.1)); + border: 1px solid var(--c-border); border-radius: 0.6rem; background: var(--color-surface-2, rgba(0, 0, 0, 0.02)); } @@ -1132,7 +1132,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-proc__license-key { display: block; @@ -1143,18 +1143,18 @@ font-family: var(--font-mono, monospace); font-size: 0.85rem; word-break: break-all; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__license-hint { margin: 0.6rem 0 0; font-size: 0.75rem; - color: var(--color-text-3, var(--color-text-2)); + color: var(--c-text-subtle, var(--c-text-muted)); } .portal-proc__milestone-for { margin: 0.15rem 0 0; font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-proc__milestone-lines { margin: 0.85rem 0 0.5rem; @@ -1169,16 +1169,16 @@ .portal-proc__milestone-annual { font-size: 1.75rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-proc__milestone-annual small { font-size: 0.8125rem; font-weight: 500; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-proc__milestone-tcv { font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* Hero next-step action row (primary CTA + optional extend-trial). */ @@ -1190,7 +1190,7 @@ /* Hero quick-action chips (clickable pills next to the company name). */ .portal-hero__chip--action { - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); cursor: pointer; transition: background 0.12s, @@ -1198,8 +1198,8 @@ transform 0.12s; } .portal-hero__chip--action:hover { - background: var(--color-surface); - box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1); + background: var(--c-surface); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); transform: translateY(-1px); } @@ -1208,10 +1208,10 @@ list-style: none; margin: 0; padding: 0; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-hero__checklist li { - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-hero__checklist button { display: flex; @@ -1225,13 +1225,13 @@ text-align: left; } .portal-hero__checklist button:hover { - background: var(--color-bg-hover, var(--color-border-light)); + background: var(--c-hover, var(--c-border-subtle)); } .portal-hero__check-dot { width: 0.5rem; height: 0.5rem; border-radius: 999px; - background: var(--color-border); + background: var(--c-border); flex-shrink: 0; } .portal-hero__check-text { @@ -1242,19 +1242,19 @@ display: block; font-size: 0.85rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-hero__check-sub { display: block; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.05rem; } .portal-hero__check-pill { font-size: 0.6875rem; font-weight: 600; - color: var(--color-text-5); - background: var(--color-border-light); + color: var(--c-text-subtle); + background: var(--c-border-subtle); border-radius: 999px; padding: 0.15rem 0.55rem; flex-shrink: 0; @@ -1270,7 +1270,7 @@ justify-content: center; padding: 1rem; overflow-y: auto; - background: rgba(15, 23, 42, 0.55); + background: var(--c-overlay); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: portal-procmodal-fade 0.15s ease-out; @@ -1281,10 +1281,10 @@ position: relative; width: 100%; max-width: 30rem; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: 14px; - box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); padding: 1.35rem 1.4rem 1.4rem; max-height: 86vh; overflow-y: auto; @@ -1302,18 +1302,18 @@ margin: 0; font-size: 1.05rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-sidemodal__sub { margin: 0.25rem 0 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); line-height: 1.5; } .portal-sidemodal__text { margin: 0; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); line-height: 1.55; } .portal-sidemodal__footer { @@ -1323,17 +1323,17 @@ gap: 0.75rem; margin-top: 1.1rem; padding-top: 0.9rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-sidemodal__ghost { border: none; background: none; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); cursor: pointer; } .portal-sidemodal__ghost:hover:not(:disabled) { - color: var(--color-text-2); + color: var(--c-text-muted); } /* Key documents ledger. */ @@ -1345,7 +1345,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-5); + color: var(--c-text-subtle); margin-bottom: 0.4rem; } .portal-docs__list { @@ -1358,7 +1358,7 @@ align-items: center; gap: 0.75rem; padding: 0.55rem 0; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-docs__row-text { flex: 1; @@ -1368,12 +1368,12 @@ display: block; font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-docs__row-sub { display: block; font-size: 0.72rem; - color: var(--color-text-4); + color: var(--c-text-subtle); margin-top: 0.05rem; } .portal-docs__row-action { @@ -1382,15 +1382,15 @@ border-radius: 999px; padding: 0.2rem 0.6rem; flex-shrink: 0; - color: var(--color-text-3); - background: var(--color-border-light); + color: var(--c-text-subtle); + background: var(--c-border-subtle); } .portal-docs__row-action[data-status="action"] { - color: var(--color-primary, #2383e2); - background: var(--color-primary-light, #eaf2fb); + color: var(--c-primary); + background: var(--c-primary-subtle); } .portal-docs__row-action[data-status="request"] { - color: var(--color-text-5); + color: var(--c-text-subtle); } /* Calendly scheduler embed (Schedule a call). The widget is always light (Calendly renders inputs on @@ -1423,18 +1423,18 @@ max-height: 22rem; overflow-y: auto; padding: 1rem 1.1rem; - border: 1px solid var(--color-border); + border: 1px solid var(--c-border); border-radius: 10px; - background: var(--color-bg-subtle, var(--color-bg)); + background: var(--color-bg-subtle, var(--c-bg)); font-size: 0.8125rem; line-height: 1.55; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-agreement__doc h4 { margin: 1rem 0 0.35rem; font-size: 0.8125rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-agreement__doc h4:first-child { margin-top: 0; @@ -1443,7 +1443,7 @@ margin: 0 0 0.5rem; } .portal-agreement__doc strong { - color: var(--color-text-1); + color: var(--c-text); } .portal-agreement__accept { margin-top: 0.25rem; @@ -1460,12 +1460,12 @@ border: none; background: none; font-size: 0.75rem; - color: var(--color-text-5); + color: var(--c-text-subtle); cursor: pointer; text-decoration: underline; } .portal-proc__reset button:hover:not(:disabled) { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-proc__reset button:disabled { opacity: 0.5; diff --git a/frontend/editor/src/portal/views/SourceBuilder.css b/frontend/editor/src/portal/views/SourceBuilder.css index 7a964a2c86..ec6b9fe4a4 100644 --- a/frontend/editor/src/portal/views/SourceBuilder.css +++ b/frontend/editor/src/portal/views/SourceBuilder.css @@ -31,7 +31,7 @@ .portal-source-builder__title { font-size: 1.375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); margin: 0; } @@ -67,7 +67,7 @@ .portal-source-builder__type-card.is-selected { border-color: var(--color-accent, var(--color-brand)); - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-source-builder__type-icon { diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index f0dea22e1c..96bd2dc5f3 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -26,14 +26,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-sources__sub { margin: 0.25rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 46rem; } @@ -58,7 +58,7 @@ .portal-sources__name-text strong { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-sources__type-dot { @@ -70,21 +70,21 @@ flex-shrink: 0; border-radius: var(--radius-md); font-size: 0.875rem; - color: var(--dot-c, var(--color-text-3)); + color: var(--dot-c, var(--c-text-subtle)); background: color-mix( in srgb, - var(--dot-c, var(--color-text-3)) 14%, + var(--dot-c, var(--c-text-subtle)) 14%, transparent ); } /* Neutral keeps a plain muted surface rather than an accent tint. */ .portal-sources__type-dot--neutral { - background: var(--color-bg-muted); - color: var(--color-text-3); + background: var(--c-surface-sunken); + color: var(--c-text-subtle); } .portal-sources__type-dot--default { - --dot-c: var(--color-blue-dark); + --dot-c: var(--c-primary-hover); } .portal-sources__type-dot--premium { --dot-c: var(--color-purple-dark); @@ -106,24 +106,24 @@ .portal-sources__muted { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-sources__caret { display: inline-block; - color: var(--color-text-5); + color: var(--c-text-subtle); transition: transform var(--motion-fast); } .portal-sources__caret.is-open { transform: rotate(90deg); - color: var(--color-blue); + color: var(--c-primary); } /* Expanded detail panel */ .portal-sources__expanded { - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: 1.25rem; @@ -147,19 +147,19 @@ gap: 0.75rem; padding-bottom: 0.875rem; margin-bottom: 1rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-sources__expanded-title { margin: 0; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-sources__expanded-sub { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-sources__expanded-close { @@ -167,9 +167,9 @@ width: 1.75rem; height: 1.75rem; border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-4); + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-subtle); font-size: 1.125rem; line-height: 1; cursor: pointer; @@ -179,8 +179,8 @@ } .portal-sources__expanded-close:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); + background: var(--c-hover); + color: var(--c-text); } /* Detail body */ @@ -213,7 +213,7 @@ .portal-sources__url { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--color-text-2); + color: var(--c-text-muted); word-break: break-all; } @@ -229,12 +229,12 @@ align-items: baseline; justify-content: space-between; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-sources__bar-head strong { font-size: 0.8125rem; - color: var(--color-text-1); + color: var(--c-text); font-family: var(--font-mono); } @@ -249,7 +249,7 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); font-weight: 600; } @@ -257,7 +257,7 @@ display: block; width: 100%; margin-top: 0.5rem; - color: var(--color-blue); + color: var(--c-primary); } .portal-sources__chips { @@ -280,7 +280,7 @@ gap: 0.625rem; padding: 0.4375rem 0.625rem; background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); } @@ -291,7 +291,7 @@ .portal-sources__endpoint-path { font-family: var(--font-mono); font-size: 0.75rem; - color: var(--color-text-2); + color: var(--c-text-muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -299,7 +299,7 @@ .portal-sources__endpoint-calls { font-size: 0.6875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); white-space: nowrap; font-family: var(--font-mono); } @@ -343,22 +343,22 @@ gap: 0.4375rem; flex: 1; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); padding: 0.5rem 0.625rem; border-radius: var(--radius-md); background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); + border: 1px solid var(--c-border-subtle); } .portal-sources__step.is-active { - color: var(--color-text-1); + color: var(--c-text); font-weight: 600; - border-color: color-mix(in srgb, var(--color-blue) 40%, transparent); - background: var(--color-blue-light); + border-color: color-mix(in srgb, var(--c-primary) 40%, transparent); + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); } .portal-sources__step.is-done { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-sources__step-mark { @@ -370,14 +370,14 @@ border-radius: 50%; font-size: 0.6875rem; font-weight: 600; - background: var(--color-surface); - border: 1px solid var(--color-border); - color: var(--color-text-4); + background: var(--c-surface); + border: 1px solid var(--c-border); + color: var(--c-text-subtle); } .portal-sources__step.is-active .portal-sources__step-mark { - background: var(--color-blue); - border-color: var(--color-blue); + background: var(--c-primary); + border-color: var(--c-primary); color: var(--color-text-on-accent); } @@ -415,8 +415,8 @@ align-items: center; gap: 0.5rem; padding: 1rem 0.5rem; - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-md); cursor: pointer; transition: @@ -425,14 +425,14 @@ } .portal-sources__type-card:hover { - border-color: var(--color-border-strong, var(--color-border)); - background: var(--color-bg-hover); + border-color: var(--color-border-strong, var(--c-border)); + background: var(--c-hover); } .portal-sources__type-card.is-selected { - border-color: var(--color-blue); - background: var(--color-blue-light); - box-shadow: 0 0 0 1px var(--color-blue) inset; + border-color: var(--c-primary); + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); + box-shadow: 0 0 0 1px var(--c-primary) inset; } /* Icon inside the type picker: a plain stroke icon above the label (no badge @@ -441,11 +441,11 @@ display: inline-flex; align-items: center; justify-content: center; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-sources__type-card.is-selected .portal-sources__type-icon { - color: var(--color-blue); + color: var(--c-primary); } .portal-sources__type-icon .portal-sources__type-svg { @@ -463,7 +463,7 @@ .portal-sources__type-name { font-size: 0.75rem; font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } .portal-sources__wizard-body { @@ -476,13 +476,13 @@ margin: 0; font-size: 0.875rem; line-height: 1.55; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-sources__wizard-note { margin: 0; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-sources__wizard-footer { @@ -533,7 +533,7 @@ .portal-sources__connections-title { font-size: 0.875rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); margin: 0 0 0.5rem; } @@ -556,11 +556,11 @@ .portal-sources__connections-name { font-weight: 500; - color: var(--color-text-1); + color: var(--c-text); } .portal-sources__connections-bucket { - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.8125rem; } @@ -579,7 +579,7 @@ } .portal-sources__connections-sub { - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 0.875rem; margin: 0; } diff --git a/frontend/editor/src/portal/views/Usage.css b/frontend/editor/src/portal/views/Usage.css index c03e790680..47c035f83e 100644 --- a/frontend/editor/src/portal/views/Usage.css +++ b/frontend/editor/src/portal/views/Usage.css @@ -11,8 +11,8 @@ position: sticky; top: 0; z-index: 5; - background: var(--color-sidebar-bg); - border-bottom: 1px solid var(--color-border); + background: var(--c-bg-raised); + border-bottom: 1px solid var(--c-border); } .portal-usage__header-inner { display: flex; @@ -35,13 +35,13 @@ margin: 0; font-size: 1.375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-usage__subtitle { margin: 0.25rem 0 0; font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Two-column row: current plan + spend cap */ @@ -67,13 +67,13 @@ margin: 0; font-size: 0.9375rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-usage__section-sub { margin: 0.25rem 0 0; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Current plan card */ @@ -96,14 +96,14 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--color-text-5); + color: var(--c-text-subtle); } .portal-usage__plan-name { margin: 0.125rem 0 0; font-size: 1.125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } /* Wallet contract card */ @@ -139,13 +139,13 @@ font-size: 2.25rem; font-weight: 600; line-height: 1; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } .portal-usage__wallet-hero-unit { font-size: 0.875rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Cap progress (free) */ @@ -160,12 +160,12 @@ align-items: center; justify-content: space-between; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-usage__cap-pct { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } @@ -183,7 +183,7 @@ gap: 0.75rem; padding: 0.5rem 0; font-size: 0.8125rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); } .portal-usage__breakdown-row:last-child { @@ -191,24 +191,24 @@ } .portal-usage__breakdown-label { - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-usage__breakdown-value { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } .portal-usage__breakdown-row--total { margin-top: 0.25rem; padding-top: 0.625rem; - border-top: 1px solid var(--color-border); + border-top: 1px solid var(--c-border); font-size: 0.875rem; } .portal-usage__breakdown-row--total .portal-usage__breakdown-label { - color: var(--color-text-1); + color: var(--c-text); font-weight: 600; } @@ -254,7 +254,7 @@ margin: 0; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-usage__plan-card-price { @@ -267,19 +267,19 @@ .portal-usage__plan-card-amount { font-size: 1.5rem; font-weight: 700; - color: var(--color-text-1); + color: var(--c-text); } .portal-usage__plan-card-cadence { font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-usage__plan-card-blurb { margin: 0; font-size: 0.8125rem; line-height: 1.45; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-usage__plan-card-features { @@ -297,7 +297,7 @@ align-items: flex-start; gap: 0.5rem; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-usage__plan-card-check { @@ -338,18 +338,18 @@ align-items: center; gap: 0.625rem; font-size: 0.75rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* Billing history */ .portal-usage__hist-date { font-variant-numeric: tabular-nums; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-usage__hist-amount { font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); font-variant-numeric: tabular-nums; } @@ -370,7 +370,7 @@ margin: 0 0 1rem; font-size: 0.875rem; line-height: 1.55; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-usage__modal-bullets { @@ -387,7 +387,7 @@ align-items: flex-start; gap: 0.5rem; font-size: 0.8125rem; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-usage__modal-actions { diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index 62c72549bf..75591cc8d8 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -19,14 +19,14 @@ margin: 0; font-size: 1.375rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__sub { margin: 0.25rem 0 0; font-size: 0.8125rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); max-width: 46rem; } @@ -39,18 +39,18 @@ margin: 0; font-size: 1rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__section-sub { margin: 0.25rem 0 0; font-size: 0.8125rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-users__muted { font-size: 0.8125rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } /* ──────────────────────────────────────────────────────────────────────── */ @@ -74,7 +74,7 @@ .portal-users__member-text strong { font-size: 0.8125rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__row-action { @@ -86,7 +86,7 @@ border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 1.125rem; line-height: 1; cursor: pointer; @@ -97,9 +97,9 @@ } .portal-users__row-action:hover { - background: var(--color-bg-hover); - border-color: var(--color-border); - color: var(--color-text-1); + background: var(--c-hover); + border-color: var(--c-border); + color: var(--c-text); } .portal-users__table-skeleton { @@ -144,7 +144,7 @@ margin: 0; font-size: 0.75rem; line-height: 1.45; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-users__role-perms { @@ -161,7 +161,7 @@ padding-left: 0.875rem; font-size: 0.6875rem; line-height: 1.4; - color: var(--color-text-3); + color: var(--c-text-subtle); } .portal-users__role-perms li::before { @@ -172,7 +172,7 @@ width: 0.25rem; height: 0.25rem; border-radius: 50%; - background: var(--color-text-5); + background: var(--c-text-subtle); } /* ──────────────────────────────────────────────────────────────────────── */ @@ -209,14 +209,14 @@ margin: 0; font-size: 0.875rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__access-note { margin: 0.5rem 0 0; font-size: 0.75rem; line-height: 1.45; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-users__access-stats { @@ -264,8 +264,8 @@ margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border-radius: 8px; - background: color-mix(in srgb, var(--sui-danger, #dc2626) 12%, transparent); - color: var(--sui-danger, #dc2626); + background: color-mix(in srgb, var(--c-danger) 12%, transparent); + color: var(--c-danger); font-size: 0.85rem; } @@ -279,7 +279,7 @@ } .portal-users__link { - color: var(--color-blue); + color: var(--c-primary); text-decoration: none; } .portal-users__link:hover { @@ -293,8 +293,8 @@ } .portal-users__group { - background: var(--color-surface); - border: 1px solid var(--color-border); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: var(--radius-lg); overflow: hidden; } @@ -305,7 +305,7 @@ justify-content: space-between; gap: 1rem; padding: 0.75rem 1.25rem; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--c-border-subtle); background: var(--color-bg-subtle); } @@ -319,15 +319,15 @@ .portal-users__group-title > strong { font-size: 0.9rem; font-weight: 650; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__group-desc { font-size: 0.8rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-users__group-count { font-size: 0.78rem; - color: var(--color-text-4); + color: var(--c-text-subtle); white-space: nowrap; } .portal-users__group-action { @@ -337,7 +337,7 @@ background: none; border: none; cursor: pointer; - color: var(--color-blue); + color: var(--c-primary); font-size: 0.8rem; font-weight: 500; white-space: nowrap; @@ -353,7 +353,7 @@ padding: 0.7rem 1.25rem; } .portal-users__row + .portal-users__row { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-users__row-main { display: flex; @@ -371,15 +371,15 @@ .portal-users__row-name { font-size: 0.875rem; font-weight: 600; - color: var(--color-text-1); + color: var(--c-text); } .portal-users__row-you { font-weight: 400; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-users__row-email { font-size: 0.78rem; - color: var(--color-text-4); + color: var(--c-text-subtle); overflow: hidden; text-overflow: ellipsis; } @@ -398,7 +398,7 @@ } .portal-users__row-active { font-size: 0.78rem; - color: var(--color-text-4); + color: var(--c-text-subtle); white-space: nowrap; } .portal-users__row-role { @@ -420,24 +420,24 @@ .portal-users__invite-hint { margin: 0; font-size: 0.78rem; - color: var(--color-text-4); + color: var(--c-text-subtle); } .portal-users__invite-access { display: flex; flex-direction: column; gap: 0.6rem; padding-top: 0.75rem; - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--c-border-subtle); } .portal-users__invite-access-label { font-size: 0.78rem; font-weight: 600; - color: var(--color-text-2); + color: var(--c-text-muted); } .portal-users__form-note { margin: 0; font-size: 0.8rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } @media (max-width: 720px) { @@ -469,7 +469,7 @@ border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; - color: var(--color-text-4); + color: var(--c-text-subtle); font-size: 1.125rem; line-height: 1; cursor: pointer; @@ -479,9 +479,9 @@ border-color var(--motion-fast); } .portal-users__row-kebab:hover { - background: var(--color-bg-hover); - border-color: var(--color-border); - color: var(--color-text-1); + background: var(--c-hover); + border-color: var(--c-border); + color: var(--c-text); } .portal-users__show-more { @@ -491,14 +491,14 @@ text-align: center; background: none; border: none; - border-top: 1px solid var(--color-border-light); - color: var(--color-blue); + border-top: 1px solid var(--c-border-subtle); + color: var(--c-primary); font-size: 0.8rem; font-weight: 500; cursor: pointer; } .portal-users__show-more:hover { - background: var(--color-bg-hover); + background: var(--c-hover); } .portal-users__row-tag { @@ -525,5 +525,5 @@ margin: 0; font-size: 0.875rem; line-height: 1.5; - color: var(--color-text-3); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/proprietary/auth/ui/AuthDefaultCredentials.tsx b/frontend/editor/src/proprietary/auth/ui/AuthDefaultCredentials.tsx index 9d7c7858ed..9f1a6dd3f4 100644 --- a/frontend/editor/src/proprietary/auth/ui/AuthDefaultCredentials.tsx +++ b/frontend/editor/src/proprietary/auth/ui/AuthDefaultCredentials.tsx @@ -10,30 +10,17 @@ export default function AuthDefaultCredentials() { return ( - + {t("login.defaultCredentials", "Default Login Credentials")} - - + + {t("login.username", "Username")}: {" "} admin - - + + {t("login.password", "Password")}: {" "} stirling @@ -42,7 +29,7 @@ export default function AuthDefaultCredentials() { size="xs" ta="center" mt="xs" - style={{ color: "var(--text-muted)" }} + style={{ color: "var(--c-text-subtle)" }} > {t( "login.changePasswordWarning", diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css index c1b843e804..8580d59e0b 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css +++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css @@ -33,20 +33,20 @@ } [data-mantine-color-scheme="dark"] { - --auth-bg-color: var(--bg-muted); - --auth-card-bg: var(--bg-surface); - --auth-label-text: var(--text-secondary); - --auth-input-border: var(--border-default); - --auth-input-bg: var(--bg-raised); - --auth-input-text: var(--text-primary); + --auth-bg-color: var(--c-surface-sunken); + --auth-card-bg: var(--c-surface); + --auth-label-text: var(--c-text-muted); + --auth-input-border: var(--c-border); + --auth-input-bg: var(--c-surface-raised); + --auth-input-text: var(--c-text); --auth-border-focus: var(--p-blue-500); --auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 20%, transparent); --auth-button-bg: var(--p-red-600); --auth-button-text: #ffffff; - --auth-magic-button-bg: var(--bg-raised); - --auth-magic-button-text: var(--text-primary); - --auth-text-primary: var(--text-primary); - --auth-text-secondary: var(--text-secondary); + --auth-magic-button-bg: var(--c-surface-raised); + --auth-magic-button-text: var(--c-text); + --auth-text-primary: var(--c-text); + --auth-text-secondary: var(--c-text-muted); --auth-error-bg: color-mix(in srgb, var(--p-red-500) 12%, transparent); --auth-error-border: color-mix(in srgb, var(--p-red-500) 35%, transparent); --auth-error-text: var(--p-red-400); @@ -59,6 +59,6 @@ --auth-success-text: var(--p-green-500); --text-divider-rule-rgb-light: 28, 35, 64; --text-divider-label-rgb-light: 91, 98, 128; - --tool-subcategory-rule-color-light: var(--border-default); - --tool-subcategory-text-color-light: var(--text-secondary); + --tool-subcategory-rule-color-light: var(--c-border); + --tool-subcategory-text-color-light: var(--c-text-muted); } diff --git a/frontend/editor/src/proprietary/auth/ui/auth.css b/frontend/editor/src/proprietary/auth/ui/auth.css index 1745e1d6b3..e4337af2d6 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth.css +++ b/frontend/editor/src/proprietary/auth/ui/auth.css @@ -139,7 +139,7 @@ .auth-checkbox { width: 1rem; /* 16px */ height: 1rem; /* 16px */ - accent-color: #af3434; + accent-color: var(--c-brand); } .auth-terms-label { @@ -267,25 +267,25 @@ } .oauth-button-vertical:hover:not(:disabled) { - background-color: var(--hover-bg); + background-color: var(--c-hover); box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12); transform: translateY(-1px); - border-color: var(--border-default); + border-color: var(--c-border); } .oauth-button-vertical-tinted { background: linear-gradient( 90deg, - color-mix(in srgb, var(--oauth-accent) 65%, #0f172a) 0 6px, - #0f172a 6px 100% + color-mix(in srgb, var(--oauth-accent) 65%, var(--c-text)) 0 6px, + var(--c-text) 6px 100% ); } .oauth-button-vertical-tinted:hover:not(:disabled) { background: linear-gradient( 90deg, - color-mix(in srgb, var(--oauth-accent) 75%, #111827) 0 6px, - #111827 6px 100% + color-mix(in srgb, var(--oauth-accent) 75%, var(--c-text)) 0 6px, + var(--c-text) 6px 100% ); } @@ -293,7 +293,7 @@ min-height: 0; justify-content: flex-start; padding: 0.75rem 1rem; /* 12px 16px */ - border: 1px solid #d1d5db; + border: 1px solid var(--c-border); border-radius: 0.75rem; /* 12px */ background-color: var(--auth-card-bg); font-weight: 500; @@ -302,7 +302,7 @@ } .oauth-button-vertical-legacy:hover:not(:disabled) { - background-color: #f3f4f6; + background-color: var(--c-hover); color: var(--auth-text-primary); box-shadow: none; } @@ -331,28 +331,28 @@ .oauth-button-vertical-outline { background: transparent; - border: 2px solid #0f172a; - color: #0f172a; + border: 2px solid var(--c-text); + color: var(--c-text); box-shadow: none; } .oauth-button-vertical-outline:hover:not(:disabled) { - background: #0f172a; - color: #f8fafc; - box-shadow: 0 0.35rem 0.9rem rgba(15, 23, 42, 0.2); + background: var(--c-text); + color: var(--c-surface); + box-shadow: 0 0.35rem 0.9rem rgba(0, 0, 0, 0.2); } .oauth-button-vertical-light { - background: #f8fafc; - color: #0f172a; - border: 1px solid rgba(15, 23, 42, 0.12); - box-shadow: 0 0.25rem 0.75rem rgba(15, 23, 42, 0.12); + background: var(--c-surface); + color: var(--c-text); + border: 1px solid var(--c-border); + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12); } .oauth-button-vertical-light:hover:not(:disabled) { - background: #eef2f7; - color: #0f172a; - box-shadow: 0 0.35rem 0.9rem rgba(15, 23, 42, 0.16); + background: var(--c-hover); + color: var(--c-text); + box-shadow: 0 0.35rem 0.9rem rgba(0, 0, 0, 0.16); } .oauth-button-vertical:disabled { @@ -363,7 +363,7 @@ } .oauth-button-vertical:focus-visible { - outline: 3px solid rgba(59, 130, 246, 0.5); + outline: 3px solid color-mix(in srgb, var(--c-primary) 50%, transparent); outline-offset: 2px; } @@ -441,7 +441,7 @@ display: flex; align-items: center; justify-content: center; - background: var(--bg-muted); + background: var(--c-surface-sunken); border: 1px solid var(--auth-input-border); } @@ -460,8 +460,8 @@ .oauth-button-vertical-outline .oauth-icon-wrapper, .oauth-button-vertical-light .oauth-icon-wrapper { - background: rgba(15, 23, 42, 0.06); - border-color: rgba(15, 23, 42, 0.14); + background: var(--c-surface-sunken); + border-color: var(--c-border); } .oauth-button-right { display: flex; @@ -616,7 +616,7 @@ .auth-input-error:focus { border-color: var(--auth-error-text) !important; - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-danger) 10%, transparent) !important; } /* Shared auth styles extracted from inline */ @@ -680,18 +680,18 @@ /* Email login button - red CTA style matching SaaS version */ .auth-cta-button { - background-color: #af3434 !important; + background-color: var(--c-brand) !important; color: white !important; border: none !important; font-weight: 600 !important; } .auth-cta-button:hover:not(:disabled) { - background-color: #9a2e2e !important; + background-color: var(--c-brand-hover) !important; } .auth-cta-button:disabled { - background-color: #af3434 !important; + background-color: var(--c-brand) !important; opacity: 0.6 !important; } @@ -742,8 +742,8 @@ } .auth-expandable-trigger--active { - border-color: #af3434 !important; - box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12); + border-color: var(--c-brand) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-brand) 12%, transparent); } /* ── Animated expand/collapse via grid-template-rows ───────────────── */ @@ -781,12 +781,12 @@ own padding-block); horizontal stays in the shorthand. */ --sui-btn-py: 0.625rem; /* 10px */ padding: 0.625rem 1rem; /* 10px 16px */ - border: 1px solid #d1d5db; + border: 1px solid var(--c-border); border-radius: 0.625rem; /* 10px */ - background-color: #ffffff; + background-color: var(--c-surface); font-size: 0.9375rem; /* 15px */ font-weight: 600; - color: #000000; + color: var(--c-text); cursor: pointer; gap: 0.5rem; box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); @@ -802,20 +802,20 @@ } .oauth-button-fullwidth:hover:not(:disabled) { - background-color: #fafafa; + background-color: var(--c-hover); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } [data-mantine-color-scheme="dark"] .oauth-button-fullwidth { - background-color: var(--bg-surface); - color: var(--text-primary); - border-color: var(--border-default); + background-color: var(--c-surface); + color: var(--c-text); + border-color: var(--c-border); box-shadow: none; } [data-mantine-color-scheme="dark"] .oauth-button-fullwidth:hover:not(:disabled) { - background-color: var(--bg-raised); + background-color: var(--c-surface-raised); box-shadow: none; } diff --git a/frontend/editor/src/proprietary/billing/MeterBar.tsx b/frontend/editor/src/proprietary/billing/MeterBar.tsx index bb15fa44aa..ee03d62885 100644 --- a/frontend/editor/src/proprietary/billing/MeterBar.tsx +++ b/frontend/editor/src/proprietary/billing/MeterBar.tsx @@ -1,5 +1,13 @@ import type { ReactNode } from "react"; import type { MeterState } from "@app/billing/format"; +import { StatusBadge, type StatusTone } from "@app/ui/StatusBadge"; + +// Meter state → the shared StatusBadge tone (contrast-tuned per theme). +const STATE_TONE: Record = { + FULL: "success", + WARNED: "warning", + DEGRADED: "danger", +}; interface MeterBarProps { state: MeterState; @@ -41,10 +49,9 @@ export function MeterBar({ {capSuffix}
{statusLabel != null && ( - - + {statusLabel} - + )}
{showBar && ( diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx index 82dd48ed5a..1df1995d02 100644 --- a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx +++ b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx @@ -107,7 +107,7 @@ export function LoginLandingRedirect() { position: "fixed", inset: 0, zIndex: Z_INDEX_SIGN_IN_MODAL, - background: "var(--bg-surface)", + background: "var(--c-surface)", }} > diff --git a/frontend/editor/src/proprietary/components/chat/ChatPanel.css b/frontend/editor/src/proprietary/components/chat/ChatPanel.css index 58c3ea4619..e95078bcdf 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatPanel.css +++ b/frontend/editor/src/proprietary/components/chat/ChatPanel.css @@ -8,7 +8,7 @@ /* Match the right-rail toolbar background so dark-mode doesn't show a lighter card behind the chat, and so the agent-card → pill morph doesn't flash a different colour mid-transition. */ - background: var(--bg-toolbar); + background: var(--c-bg-raised); } .chat-panel--embedded { @@ -31,7 +31,7 @@ flex: 1; min-width: 0; padding: 0.4rem 0.75rem 0.4rem 0.4rem; - border: 1px solid var(--border-subtle); + border: 1px solid var(--c-border-subtle); border-radius: 9999px; background: var(--mantine-color-body); cursor: pointer; @@ -103,7 +103,7 @@ border-color: color-mix( in srgb, var(--mantine-color-blue-5) 60%, - var(--border-subtle) + var(--c-border-subtle) ); } @@ -111,7 +111,7 @@ border-color: color-mix( in srgb, var(--mantine-color-blue-4) 55%, - var(--border-subtle) + var(--c-border-subtle) ); } @@ -119,14 +119,14 @@ so it doesn't read as a clashing lighter card on the dark toolbar. */ [data-mantine-color-scheme="dark"] .chat-panel__agent-pill { background: transparent; - border-color: var(--border-subtle); + border-color: var(--c-border-subtle); } /* The default tertiary accent is blue — use the surface text colour instead so the pill label and close icon don't look like interactive links. Overrides the Mantine button colour var the shared Button sets inline. */ .chat-panel__header .sui-btn--tertiary { - --button-color: var(--text-primary) !important; + --button-color: var(--c-text) !important; } [data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover { @@ -146,7 +146,7 @@ [data-mantine-color-scheme="dark"] .chat-panel-input { background: transparent; box-shadow: - 0 0 0 1px var(--border-subtle), + 0 0 0 1px var(--c-border-subtle), 0 6px 16px rgba(0, 0, 0, 0.25); } @@ -162,7 +162,7 @@ [data-mantine-color-scheme="dark"] .chat-quick-action:hover { background: rgba(255, 255, 255, 0.04); - border-color: var(--border-subtle); + border-color: var(--c-border-subtle); } .chat-panel__agent-pill-label { @@ -271,8 +271,8 @@ .chat-file-pill--more { background: transparent; - border: 1px dashed var(--border-subtle); - color: var(--text-muted); + border: 1px dashed var(--c-border-subtle); + color: var(--c-text-subtle); cursor: pointer; padding: 0.2rem 0.55rem; } @@ -285,7 +285,7 @@ display: block; width: 100%; padding: 0.6rem 0.75rem; - border: 1px solid var(--border-subtle); + border: 1px solid var(--c-border-subtle); border-radius: 0.65rem; background: var(--mantine-color-body); transition: @@ -310,6 +310,14 @@ flex-shrink: 0; } +/* Let the label fill the row and align left so the badge sits flush left and + the chevron (rightSection) is pushed to the far right. */ +.chat-quick-action .mantine-Button-label { + flex: 1; + min-width: 0; + text-align: left; +} + /* Input area: send button on the left, textarea fills the rest — one compact row so the composer stays short when the welcome screen is fully populated. */ .chat-panel-input { @@ -325,7 +333,7 @@ flex-shrink: 0; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), - 0 4px 14px rgba(15, 23, 42, 0.08); + 0 4px 14px rgba(0, 0, 0, 0.08); transition: box-shadow 160ms ease-out; } @@ -365,7 +373,7 @@ align-items: center; gap: 0.45rem; flex-shrink: 0; - color: var(--text-muted); + color: var(--c-text-subtle); } .chat-panel-disclaimer__icon { @@ -377,7 +385,7 @@ .chat-panel-disclaimer--banner { margin: 0.35rem 0.75rem 0; padding: 0.6rem 0.75rem; - border: 1px solid var(--border-subtle); + border: 1px solid var(--c-border-subtle); border-radius: 0.65rem; background: var(--mantine-color-gray-light); font-size: 0.78rem; @@ -440,7 +448,7 @@ border: none; border-radius: 0.375rem; background: transparent; - color: var(--text-muted); + color: var(--c-text-subtle); cursor: pointer; transition: background 100ms ease-out, @@ -458,7 +466,7 @@ .chat-message-timestamp { font-size: 0.7rem; - color: var(--text-muted); + color: var(--c-text-subtle); padding: 0 0.25rem; white-space: nowrap; } @@ -526,11 +534,11 @@ line-height: 1.35; background: linear-gradient( 90deg, - var(--text-muted) 0%, - var(--text-muted) 40%, + var(--c-text-subtle) 0%, + var(--c-text-subtle) 40%, var(--mantine-color-text) 50%, - var(--text-muted) 60%, - var(--text-muted) 100% + var(--c-text-subtle) 60%, + var(--c-text-subtle) 100% ); background-size: 200% 100%; background-clip: text; @@ -548,7 +556,7 @@ height: 20px; flex-shrink: 0; overflow: hidden; - color: var(--text-muted); + color: var(--c-text-subtle); animation: chat-phase-pulse 1.6s ease-in-out infinite; } @@ -603,7 +611,7 @@ align-items: center; padding: 0.1rem 0.25rem 0.1rem 0.1rem; border-radius: 0.35rem; - color: var(--text-muted); + color: var(--c-text-subtle); transition: background 120ms ease-out; } @@ -640,7 +648,7 @@ min-width: 1.1rem; text-align: right; font-size: 0.75rem; - color: var(--text-muted); + color: var(--c-text-subtle); font-variant-numeric: tabular-nums; } @@ -652,7 +660,7 @@ height: 18px; flex-shrink: 0; overflow: hidden; - color: var(--text-muted); + color: var(--c-text-subtle); } .chat-completed-log__tool-name { @@ -676,8 +684,8 @@ .chat-progress-live__label { animation: none; background: none; - -webkit-text-fill-color: var(--text-muted); - color: var(--text-muted); + -webkit-text-fill-color: var(--c-text-subtle); + color: var(--c-text-subtle); } .chat-progress-live__phase-icon { animation: none; diff --git a/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx b/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx index f650c953fd..408cb757f3 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Stack, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; @@ -32,30 +32,37 @@ function QuickActionCard({ action }: { action: QuickAction }) { type="button" variant="tertiary" hover={false} + fullWidth + py="sm" + justify="start" className="chat-quick-action" onClick={action.onClick} aria-label={action.title} - > - - {action.icon} - - - {action.title} - - {action.subtitle && ( - - {action.subtitle} - - )} + leftSection={ + + {action.icon} + } + rightSection={ - + } + > + + + {action.title} + + {action.subtitle && ( + + {action.subtitle} + + )} + ); } diff --git a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css index b967707164..3917f482e6 100644 --- a/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css +++ b/frontend/editor/src/proprietary/components/policies/ClassificationCategoryManager.css @@ -17,17 +17,17 @@ display: inline-flex; align-items: center; flex: none; - color: var(--color-text-2); + color: var(--c-text-muted); } .category-visibility-name { flex: 1; min-width: 0; font-size: 0.875rem; - color: var(--color-text-1); + color: var(--c-text); } .category-visibility-count { font-size: 0.75rem; - color: var(--color-text-3); + color: var(--c-text-subtle); } /* Hidden categories read as dimmed until re-shown. */ .category-visibility-row--hidden .category-visibility-icon, diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx index 92cc4a556f..45dea92070 100644 --- a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -40,7 +40,7 @@ export function PolicyEnforcingOverlay({ if (!enforcing) return null; return ( @@ -159,8 +159,8 @@ export default function ApiKeys() { style={{ padding: 18, borderRadius: 12, - background: "var(--api-keys-card-bg)", - border: "1px solid var(--api-keys-card-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)", }} > diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 68c2f49536..10e9f39fb0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -640,7 +640,10 @@ export default function PeopleSection() { key={user.id} style={ isCurrentUser(user) - ? { backgroundColor: "rgba(34, 139, 230, 0.08)" } + ? { + backgroundColor: + "color-mix(in srgb, var(--c-primary) 8%, transparent)", + } : undefined } > diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx index f05817a957..6a57c9a1e0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx @@ -27,8 +27,8 @@ export default function ApiKeySection({ radius="md" p={18} style={{ - background: "var(--api-keys-card-bg)", - border: "1px solid var(--api-keys-card-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)", }} > @@ -36,8 +36,8 @@ export default function ApiKeySection({ @@ -221,14 +221,14 @@ export function CardExpansionModal({ display: "flex", flexDirection: "column", minHeight: 0, - backgroundColor: "var(--bg-toolbar)", + backgroundColor: "var(--c-bg-raised)", }} > {toolbar && (
{toolbar} @@ -251,7 +251,7 @@ export function CardExpansionModal({
{children} @@ -262,7 +262,7 @@ export function CardExpansionModal({
diff --git a/frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx b/frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx index a6a983ca3d..a8d7499878 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/StatCard.tsx @@ -30,9 +30,9 @@ export function StatCard({ style={{ padding: "0.5rem 0.75rem 2rem", borderRadius: "var(--mantine-radius-sm)", - border: `0.0625rem solid ${isActive && hoverColor ? hoverColor : "var(--border-subtle)"}`, + border: `0.0625rem solid ${isActive && hoverColor ? hoverColor : "var(--c-border-subtle)"}`, backgroundColor: - isActive && hoverColor ? `${hoverColor}10` : "var(--bg-toolbar)", + isActive && hoverColor ? `${hoverColor}10` : "var(--c-bg-raised)", textAlign: "center", cursor: isClickable ? "pointer" : "default", transition: "border-color 0.15s ease, background-color 0.15s ease", @@ -43,7 +43,7 @@ export function StatCard({ }} onMouseLeave={(e) => { if (isClickable && !isActive) - e.currentTarget.style.borderColor = "var(--border-subtle)"; + e.currentTarget.style.borderColor = "var(--c-border-subtle)"; }} >
{icon}
diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx index 44b2b0c657..90e083fb23 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx @@ -78,7 +78,8 @@ export function WatchedFolderCard({ style={ isDragOver ? { - backgroundColor: "rgba(59,130,246,0.10)", + backgroundColor: + "color-mix(in srgb, var(--c-primary) 10%, transparent)", borderRadius: "var(--mantine-radius-sm)", } : undefined diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx index f1110c9a2a..0f0b2b5bd5 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx @@ -120,7 +120,7 @@ function FolderCard({ ? "var(--mantine-color-blue-filled)" : isDone ? "var(--color-green-500)" - : "var(--text-muted)"; + : "var(--c-text-subtle)"; const statusDotPulse = isActive; const statusLabel = isPaused @@ -197,7 +197,7 @@ function FolderCard({
diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx index 362d4cf7b0..9093c84d4b 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx @@ -58,7 +58,7 @@ function SectionLabel({ children }: { children: string }) { tt="uppercase" style={{ letterSpacing: "0.06em", - color: "var(--tool-subcategory-text-color)", + color: "var(--c-text-subtle)", marginBottom: "0.5rem", }} > @@ -330,7 +330,7 @@ export function WatchedFolderManagementModal({ style={{ width: "28rem", flexShrink: 0, - borderRight: "0.0625rem solid var(--border-subtle)", + borderRight: "0.0625rem solid var(--c-border-subtle)", display: "flex", flexDirection: "column", overflow: "hidden", @@ -439,7 +439,7 @@ export function WatchedFolderManagementModal({ style={{ marginLeft: "0.75rem", paddingLeft: "0.75rem", - borderLeft: "2px solid var(--border-subtle)", + borderLeft: "2px solid var(--c-border-subtle)", }} > diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx index 67073a023a..94783a0929 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderSection.tsx @@ -78,8 +78,8 @@ export function WatchedFolderSection() { return ( @@ -893,7 +893,7 @@ export function WatchedFolderWorkbenchView({ width: "0.75rem", height: "0.75rem", borderRadius: "50%", - backgroundColor: "var(--bg-toolbar)", + backgroundColor: "var(--c-bg-raised)", textAlign: "center", display: "flex", alignItems: "center", @@ -1221,7 +1221,7 @@ export function WatchedFolderWorkbenchView({ style={{ height: "0.1875rem", borderRadius: "999px", - backgroundColor: "var(--border-subtle)", + backgroundColor: "var(--c-border-subtle)", overflow: "hidden", }} > @@ -1263,7 +1263,7 @@ export function WatchedFolderWorkbenchView({ flex: 1, color: isDragOver ? "var(--mantine-color-blue-filled)" - : "var(--tool-subcategory-text-color)", + : "var(--c-text-subtle)", textTransform: "uppercase", letterSpacing: "0.05em", }} @@ -1361,8 +1361,8 @@ export function WatchedFolderWorkbenchView({ {[0, 25, 50, 75].map((pct) => ( @@ -2205,7 +2205,7 @@ export function WatchedFolderWorkbenchView({ top: `${pct}%`, left: 0, right: 0, - borderTop: "0.0625rem dashed var(--border-subtle)", + borderTop: "0.0625rem dashed var(--c-border-subtle)", pointerEvents: "none", }} /> @@ -2245,8 +2245,9 @@ export function WatchedFolderWorkbenchView({ ? `calc(100% - ${chartHover.relX}px + 12px)` : undefined, top: Math.max(chartHover.relY - 36, 4), - backgroundColor: "var(--bg-toolbar)", - border: "0.0625rem solid var(--border-subtle)", + backgroundColor: "var(--c-bg-raised)", + border: + "0.0625rem solid var(--c-border-subtle)", borderRadius: "var(--mantine-radius-sm)", padding: "0.3rem 0.5rem", pointerEvents: "none", diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css index d41693db12..86c20d5b6e 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolders.css @@ -26,8 +26,8 @@ position: relative; display: flex; flex-direction: column; - background: var(--bg-surface); - border: 1px solid var(--border-subtle); + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); border-radius: 0.85rem; overflow: hidden; cursor: pointer; @@ -42,8 +42,8 @@ transform: translateY(-2px); border-color: color-mix( in srgb, - var(--accent-interactive, #6366f1) 35%, - var(--border-subtle) + var(--c-primary) 35%, + var(--c-border-subtle) ); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), @@ -51,25 +51,21 @@ } .wf-card:focus-visible { - outline: 2px solid var(--accent-interactive, #6366f1); + outline: 2px solid var(--c-primary); outline-offset: 2px; } .wf-card.is-drop-target { - background: color-mix( - in srgb, - var(--accent-interactive, #6366f1) 10%, - var(--bg-surface) - ); - box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1); - border-color: var(--accent-interactive, #6366f1); + background: color-mix(in srgb, var(--c-primary) 10%, var(--c-surface)); + box-shadow: 0 0 0 2px var(--c-primary); + border-color: var(--c-primary); } /* Dragging a file the folder already contains — signal it's a no-op, not an add. */ .wf-card.is-already-member { - background: color-mix(in srgb, #f59e0b 8%, var(--bg-surface)); - box-shadow: 0 0 0 2px #f59e0b; - border-color: #f59e0b; + background: color-mix(in srgb, var(--c-warning) 8%, var(--c-surface)); + box-shadow: 0 0 0 2px var(--c-warning); + border-color: var(--c-warning); } .wf-card-drag-message { @@ -83,8 +79,8 @@ padding: 0.5rem; font-size: 0.8rem; font-weight: 600; - color: var(--text-primary); - background: color-mix(in srgb, var(--bg-surface) 72%, transparent); + color: var(--c-text); + background: color-mix(in srgb, var(--c-surface) 72%, transparent); backdrop-filter: blur(1px); border-radius: inherit; /* Let drag/drop events pass through to the card underneath. */ @@ -97,8 +93,8 @@ justify-content: center; background: linear-gradient( 180deg, - color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)), - var(--bg-surface) + color-mix(in srgb, var(--c-text-subtle) 5%, var(--c-surface)), + var(--c-surface) ); aspect-ratio: 1.6 / 1; padding: 0.75rem; @@ -118,13 +114,13 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - color: var(--text-primary); + color: var(--c-text); letter-spacing: -0.005em; } .wf-card-meta { font-size: 0.76rem; - color: var(--text-muted); + color: var(--c-text-subtle); display: flex; align-items: center; gap: 0.4rem; @@ -166,12 +162,12 @@ justify-content: center; gap: 0.5rem; text-align: center; - background: var(--bg-surface); - border: 1.5px dashed var(--border-subtle); + background: var(--c-surface); + border: 1.5px dashed var(--c-border-subtle); border-radius: 0.85rem; padding: 1rem; cursor: pointer; - color: var(--text-muted); + color: var(--c-text-subtle); min-height: 9rem; transition: border-color 0.14s ease, @@ -180,13 +176,9 @@ } .wf-new-tile:hover { - border-color: var(--accent-interactive, #6366f1); - color: var(--accent-interactive, #6366f1); - background: color-mix( - in srgb, - var(--accent-interactive, #6366f1) 6%, - var(--bg-surface) - ); + border-color: var(--c-primary); + color: var(--c-primary); + background: color-mix(in srgb, var(--c-primary) 6%, var(--c-surface)); } .wf-new-tile-icon { @@ -196,12 +188,8 @@ width: 3rem; height: 3rem; border-radius: 50%; - background: color-mix( - in srgb, - var(--accent-interactive, #6366f1) 12%, - transparent - ); - color: var(--accent-interactive, #6366f1); + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + color: var(--c-primary); } .wf-empty { @@ -211,7 +199,7 @@ justify-content: center; gap: 0.75rem; padding: 4rem 1.5rem; - color: var(--text-muted); + color: var(--c-text-subtle); text-align: center; } @@ -222,25 +210,21 @@ width: 5rem; height: 5rem; border-radius: 50%; - background: color-mix( - in srgb, - var(--accent-interactive, #6366f1) 12%, - transparent - ); - color: var(--accent-interactive, #6366f1); + background: color-mix(in srgb, var(--c-primary) 12%, transparent); + color: var(--c-primary); margin-bottom: 0.25rem; } .wf-empty-title { font-size: 1.15rem; - color: var(--text-primary); + color: var(--c-text); font-weight: 600; letter-spacing: -0.01em; } .wf-empty-hint { font-size: 0.9rem; - color: var(--text-muted); + color: var(--c-text-subtle); max-width: 30rem; line-height: 1.5; } diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index c377df1794..acb6a6e9c8 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -142,11 +142,13 @@ export default function Landing() { padding: "1.5rem", marginTop: "1rem", borderRadius: "0.75rem", - backgroundColor: "rgba(37, 99, 235, 0.08)", - border: "1px solid rgba(37, 99, 235, 0.2)", + backgroundColor: + "color-mix(in srgb, var(--c-primary) 8%, transparent)", + border: + "1px solid color-mix(in srgb, var(--c-primary) 20%, transparent)", }} > -

+

{t( "backendStartup.unreachable", "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again.", diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index b21b9e1dd6..3e6764bf60 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -440,11 +440,13 @@ export default function Login() { padding: "1.5rem", marginTop: "1rem", borderRadius: "0.75rem", - backgroundColor: "rgba(37, 99, 235, 0.08)", - border: "1px solid rgba(37, 99, 235, 0.2)", + backgroundColor: + "color-mix(in srgb, var(--c-primary) 8%, transparent)", + border: + "1px solid color-mix(in srgb, var(--c-primary) 20%, transparent)", }} > -

+

{t( "backendStartup.unreachable", "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again.", @@ -480,10 +482,12 @@ export default function Login() { style={{ padding: "1rem", marginBottom: "1rem", - backgroundColor: "rgba(34, 197, 94, 0.1)", - border: "1px solid rgba(34, 197, 94, 0.3)", + backgroundColor: + "color-mix(in srgb, var(--c-success) 10%, transparent)", + border: + "1px solid color-mix(in srgb, var(--c-success) 30%, transparent)", borderRadius: "0.5rem", - color: "#16a34a", + color: "var(--c-success)", }} >

@@ -32,7 +32,7 @@ export default function LoggedInState() { style={{ maxWidth: "400px", width: "100%", - backgroundColor: "#ffffff", + backgroundColor: "var(--c-surface)", borderRadius: "16px", boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1)", padding: "32px", @@ -55,19 +55,19 @@ export default function LoggedInState() { style={{ fontSize: "24px", fontWeight: "bold", - color: "#059669", + color: "var(--c-success)", marginBottom: "8px", }} > {t("login.youAreLoggedIn")} -

+

{t("login.email")}: {user?.email}

-

+

Redirecting to home...

diff --git a/frontend/editor/src/saas/components/auth/GuestUserBanner.css b/frontend/editor/src/saas/components/auth/GuestUserBanner.css index 40ef91adc7..2dc1ba528d 100644 --- a/frontend/editor/src/saas/components/auth/GuestUserBanner.css +++ b/frontend/editor/src/saas/components/auth/GuestUserBanner.css @@ -3,8 +3,8 @@ top: 1rem; right: 4.5rem; z-index: 1000; - background: var(--modal-content-bg, #111418); - border: 1px solid var(--api-keys-card-border, rgba(255, 255, 255, 0.08)); + background: var(--c-surface); + border: 1px solid var(--c-border); border-radius: 12px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); padding: 12px 16px; diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 32def0b196..3f7b4de5ab 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -130,13 +130,13 @@ const AppConfigModal: React.FC = ({ const colors = useMemo( () => ({ - navBg: "var(--modal-nav-bg)", - sectionTitle: "var(--modal-nav-section-title)", + navBg: "var(--c-bg-raised)", + sectionTitle: "var(--c-text-subtle)", navItem: "var(--modal-nav-item)", - navItemActive: "var(--modal-nav-item-active)", - navItemActiveBg: "var(--modal-nav-item-active-bg)", - contentBg: "var(--modal-content-bg)", - headerBorder: "var(--modal-header-border)", + navItemActive: "var(--c-accent-fg)", + navItemActiveBg: "var(--c-primary-subtle)", + contentBg: "var(--c-surface)", + headerBorder: "var(--c-border-subtle)", }), [], ); diff --git a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.css b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.css index afb382969b..04c19f5f02 100644 --- a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.css +++ b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.css @@ -1,6 +1,6 @@ .chart-tooltip { - background: var(--bg-surface); - color: var(--text-primary); + background: var(--c-surface); + color: var(--c-text); border: 1px solid transparent; box-shadow: var(--shadow-md); padding: 8px 10px; @@ -9,6 +9,6 @@ border-radius: 8px; } [data-mantine-color-scheme="dark"] .chart-tooltip { - border-color: var(--border-subtle); + border-color: var(--c-border-subtle); box-shadow: none; } diff --git a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx index b1ba0ed3ac..5f56843dae 100644 --- a/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx +++ b/frontend/editor/src/saas/components/shared/charts/StackedBarChart.tsx @@ -108,7 +108,7 @@ export default function StackedBarChart({ .attr("rx", radius) .attr("ry", radius) .attr("fill", "var(--usage-inactive)") - .attr("stroke", "var(--api-keys-card-border)"); + .attr("stroke", "var(--c-border)"); // Define a clipPath that will reveal the used portion from left to right const defs = svg.append("defs"); @@ -308,7 +308,7 @@ export default function StackedBarChart({ background: "var(--usage-inactive)", display: "inline-block", borderRadius: 2, - outline: "1px solid var(--api-keys-card-border)", + outline: "1px solid var(--c-border)", }} /> {t("common.remaining", "Remaining")} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx index 4d65073f71..19883e5531 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx @@ -74,8 +74,8 @@ export default function ApiKeys() { radius="md" p={18} style={{ - background: "var(--api-keys-card-bg)", - border: "1px solid var(--api-keys-card-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)", }} > @@ -111,8 +111,8 @@ export default function ApiKeys() { style={{ padding: 18, borderRadius: 12, - background: "var(--api-keys-card-bg)", - border: "1px solid var(--api-keys-card-border)", + background: "var(--c-surface)", + border: "1px solid var(--c-border)", boxShadow: "0 2px 8px var(--api-keys-card-shadow)", }} > diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 3a18322d06..ab75b7bb79 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -334,7 +334,7 @@ export default function Login() {

@@ -429,7 +429,7 @@ export default function Login() { cursor: "pointer", fontSize: "1rem", fontWeight: 700, - color: "var(--text-primary)", + color: "var(--c-text)", }} > {isSigningIn @@ -454,7 +454,7 @@ export default function Login() { border: "none", cursor: "pointer", fontSize: "0.875rem", - color: "#9ca3af", + color: "var(--c-text-subtle)", }} > {t("login.createAccount", "Create an account")} diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx index 3c34626168..942cfefb5a 100644 --- a/frontend/editor/src/saas/routes/Signup.tsx +++ b/frontend/editor/src/saas/routes/Signup.tsx @@ -260,7 +260,7 @@ export default function Signup() { cursor: "pointer", fontSize: "1rem", fontWeight: 700, - color: "var(--text-primary)", + color: "var(--c-text)", }} > {isSigningUp @@ -285,7 +285,7 @@ export default function Signup() { border: "none", cursor: "pointer", fontSize: "0.875rem", - color: "#9ca3af", + color: "var(--c-text-subtle)", }} > {t("signup.alreadyHaveAccount", "I already have an account")} diff --git a/frontend/editor/src/saas/routes/authShared/saas-auth.css b/frontend/editor/src/saas/routes/authShared/saas-auth.css index 96d97f695c..23067d77f9 100644 --- a/frontend/editor/src/saas/routes/authShared/saas-auth.css +++ b/frontend/editor/src/saas/routes/authShared/saas-auth.css @@ -14,9 +14,9 @@ z-index: 40; margin-top: 0.5rem; min-width: 16rem; - background-color: #ffffff; - color: #000000; - border: 1px solid #e5e7eb; + background-color: var(--c-surface); + color: var(--c-text); + border: 1px solid var(--c-border); border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), @@ -25,9 +25,6 @@ } [data-mantine-color-scheme="dark"] .auth-dropdown { - background-color: var(--bg-surface); - color: var(--text-primary); - border-color: var(--border-default); box-shadow: var(--shadow-md); } @@ -39,17 +36,13 @@ border: none; padding: 0.5rem 0.75rem; border-radius: 0.375rem; - color: #000; + color: var(--c-text); cursor: pointer; } -[data-mantine-color-scheme="dark"] .auth-dropdown-item { - color: var(--text-primary); -} - .auth-guest-button { - background-color: #ffffff; - color: #9c2f30; + background-color: var(--c-surface); + color: var(--c-brand); border: 2px solid currentColor; } @@ -93,8 +86,8 @@ } .auth-expandable-trigger--active { - border-color: #af3434 !important; - box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12); + border-color: var(--c-brand) !important; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-brand) 12%, transparent); } /* ── Animated expand/collapse via grid-template-rows ───────────────── */ diff --git a/frontend/editor/src/saas/styles/saas-theme.css b/frontend/editor/src/saas/styles/saas-theme.css index 44a016eb5d..0d6b0d4940 100644 --- a/frontend/editor/src/saas/styles/saas-theme.css +++ b/frontend/editor/src/saas/styles/saas-theme.css @@ -119,20 +119,20 @@ --color-orange-400: var(--p-red-600); /* Auth page colors (dark mode) — mirror proprietary so the auth card themes dark */ - --auth-bg-color: var(--bg-muted); - --auth-card-bg: var(--bg-surface); - --auth-label-text: var(--text-secondary); - --auth-input-border: var(--border-default); - --auth-input-bg: var(--bg-raised); - --auth-input-text: var(--text-primary); + --auth-bg-color: var(--c-surface-sunken); + --auth-card-bg: var(--c-surface); + --auth-label-text: var(--c-text-muted); + --auth-input-border: var(--c-border); + --auth-input-bg: var(--c-surface-raised); + --auth-input-text: var(--c-text); --auth-border-focus: var(--p-blue-500); --auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 20%, transparent); --auth-button-bg: var(--p-red-600); --auth-button-text: #ffffff; - --auth-magic-button-bg: var(--bg-raised); - --auth-magic-button-text: var(--text-primary); - --auth-text-primary: var(--text-primary); - --auth-text-secondary: var(--text-secondary); + --auth-magic-button-bg: var(--c-surface-raised); + --auth-magic-button-text: var(--c-text); + --auth-text-primary: var(--c-text); + --auth-text-secondary: var(--c-text-muted); --auth-error-bg: color-mix(in srgb, var(--p-red-500) 12%, transparent); --auth-error-border: color-mix(in srgb, var(--p-red-500) 35%, transparent); --auth-error-text: var(--p-red-400); From 1e2895a79f92a41abce7a5105bfabc9bdc85f373 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:18:39 +0000 Subject: [PATCH 088/109] Add external-API integrations plus pipeline steps (#7098) New Generic API mode with examples and integrations setup around it - Adds an integration operations catalogue so external-API connections (e.g. Microsoft Purview) can be used as policy pipeline steps - New generic external-API step calls a configured connection during a policy run, with a verdict gate to pass/fail documents on the response - Purview sensitivity-labelling step applies labels to processed documents, gated behind the Purview connection being configured (WIP to be changed later) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/model/ApplicationProperties.java | 21 + .../common/service/InternalApiClient.java | 7 +- .../access/service/SecretMasker.java | 69 ++ .../integration/api/ApiAuthType.java | 25 + .../api/ApiConnectionResolver.java | 130 +++ .../api/ApiConnectionSettings.java | 282 ++++++ .../api/ApiIntegrationValidator.java | 109 +++ .../integration/api/ApiTokenCache.java | 149 ++++ .../integration/api/ApiTokenLogin.java | 209 +++++ .../integration/api/DocumentContext.java | 180 ++++ .../api/ExternalApiCallController.java | 552 ++++++++++++ .../integration/api/ExternalApiCaller.java | 299 +++++++ .../integration/api/ExternalApiHeaders.java | 72 ++ .../integration/api/ExternalApiPaths.java | 120 +++ .../api/IntegrationStepValidator.java | 68 ++ .../integration/api/MultipartBody.java | 101 +++ .../integration/api/Placeholders.java | 153 ++++ .../integration/api/ResultFiles.java | 215 +++++ .../integration/api/ResultUrls.java | 108 +++ .../IntegrationConfigController.java | 19 + .../integration/model/IntegrationType.java | 7 +- .../purview/PdfSensitivityLabels.java | 323 +++++++ .../purview/PurviewConnectionSettings.java | 94 ++ .../purview/PurviewIntegrationValidator.java | 23 + .../purview/PurviewLabelController.java | 184 ++++ .../integration/purview/SensitivityLabel.java | 186 ++++ .../service/IntegrationConfigService.java | 32 + .../policy/controller/PolicyController.java | 28 +- .../policy/engine/PipelineStepValidator.java | 22 + .../policy/engine/PolicyValidator.java | 28 +- .../access/service/SecretMaskerTest.java | 42 + .../api/ApiIntegrationValidatorTest.java | 76 ++ .../integration/api/DocumentContextTest.java | 177 ++++ .../ExternalApiCallControllerLiveTest.java | 615 +++++++++++++ .../api/ExternalApiCallerAuthHeaderTest.java | 145 +++ .../api/ExternalApiCallerTokenLoginTest.java | 296 ++++++ .../integration/api/ExternalApiPathsTest.java | 135 +++ .../integration/api/MultipartBodyTest.java | 111 +++ .../api/PlaceholdersTemplateTest.java | 110 +++ .../integration/api/PlaceholdersTest.java | 127 +++ .../integration/api/ResultUrlsTest.java | 193 ++++ .../purview/PdfSensitivityLabelsTest.java | 282 ++++++ .../service/IntegrationConfigServiceTest.java | 106 ++- .../policy/engine/PolicyValidatorTest.java | 7 +- .../public/locales/en-US/translation.toml | 541 ++++++++++- .../editor/src/core/i18n/translationAudit.ts | 9 + frontend/editor/src/core/theme/colors.css | 7 + frontend/editor/src/core/theme/primitives.css | 4 + .../editor/src/portal/api/integrations.ts | 20 +- frontend/editor/src/portal/api/policies.ts | 9 +- .../pipelines/PipelineStepSettings.tsx | 17 + .../components/pipelines/ToolPicker.tsx | 59 +- .../pipelines/integrationStep.test.ts | 81 ++ .../components/pipelines/integrationStep.ts | 62 ++ .../policies/PolicyExternalApiConfig.test.tsx | 81 ++ .../policies/PolicyExternalApiConfig.tsx | 416 +++++++++ .../policies/PolicyPurviewConfig.tsx | 93 ++ .../policies/PolicyPurviewReadConfig.tsx | 45 + .../policies/PolicySetupWizard.test.tsx | 47 + .../components/policies/PolicySetupWizard.tsx | 84 +- .../policies/stepOperations.test.ts | 251 ++++++ .../components/policies/stepOperations.ts | 672 ++++++++++++++ .../components/sources/ConnectionForm.tsx | 105 +++ .../sources/ConnectionModal.test.tsx | 309 +++++++ .../components/sources/ConnectionModal.tsx | 221 +++++ .../sources/ConnectionPicker.test.tsx | 111 +++ .../components/sources/ConnectionPicker.tsx | 103 +++ .../sources/ConnectionTypePicker.tsx | 142 +++ .../sources/ConnectionsTab.test.tsx | 3 + .../components/sources/ConnectionsTab.tsx | 95 +- .../components/sources/S3ConnectionForm.tsx | 116 --- .../sources/S3ConnectionModal.test.tsx | 131 --- .../components/sources/S3ConnectionModal.tsx | 127 --- .../sources/S3ConnectionPicker.test.tsx | 11 +- .../components/sources/S3ConnectionPicker.tsx | 65 +- .../sources/connectionTypes.test.ts | 217 +++++ .../components/sources/connectionTypes.ts | 844 ++++++++++++++++++ .../editor/src/portal/mocks/handlers/index.ts | 2 + .../src/portal/mocks/handlers/integrations.ts | 128 +++ .../src/portal/views/PipelineBuilder.test.tsx | 76 +- .../src/portal/views/PipelineBuilder.tsx | 58 +- .../src/portal/views/SourceBuilder.test.tsx | 2 + frontend/editor/src/portal/views/Sources.css | 244 +++++ .../editor/src/portal/views/Sources.test.tsx | 3 + .../proprietary/policies/operations.test.ts | 3 + .../src/proprietary/policies/operations.ts | 94 +- frontend/eslint.config.mjs | 11 + 87 files changed, 11399 insertions(+), 557 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java create mode 100644 frontend/editor/src/portal/components/pipelines/integrationStep.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/integrationStep.ts create mode 100644 frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewConfig.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.tsx create mode 100644 frontend/editor/src/portal/components/policies/stepOperations.test.ts create mode 100644 frontend/editor/src/portal/components/policies/stepOperations.ts create mode 100644 frontend/editor/src/portal/components/sources/ConnectionForm.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionModal.test.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionModal.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionPicker.test.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionPicker.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionTypePicker.tsx delete mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx delete mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx delete mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx create mode 100644 frontend/editor/src/portal/components/sources/connectionTypes.test.ts create mode 100644 frontend/editor/src/portal/components/sources/connectionTypes.ts create mode 100644 frontend/editor/src/portal/mocks/handlers/integrations.ts 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 2aa8413cf6..b20dc298f7 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 @@ -250,6 +250,27 @@ public class ApplicationProperties { */ private boolean allowPrivateS3Endpoints = false; + /** + * Whether an API/Purview/ConsignO integration's base URL may resolve to a loopback, + * link-local, or private address. Off by default: unlike S3 connections, any user may + * create one of these, so without this gate a user could point a connection at the cloud + * metadata address and have the server fetch it for them. Enable only when integrations + * genuinely live inside the network (e.g. an on-prem ConsignO or an internal API gateway). + */ + private boolean allowPrivateApiEndpoints = false; + + /** + * Whether administrators may define their own API integrations - a free-form base URL, + * path, body and headers - as opposed to only using the built-in vendor presets (Purview, + * ConsignO, S3). On by default, and admin-only regardless: a custom integration can point + * the server at any host, so it is authoring power, not self-serve. + * + *

Turning this off stops new custom integrations being created or edited. Ones that + * already exist keep running, because a policy that silently stopped calling out would be a + * worse surprise than one that keeps working; disable the connection itself to stop it. + */ + private boolean allowCustomApiIntegrations = true; + private long webhookMaxBytes = 104857600L; } 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 63d577ca85..dd49ab3f51 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 @@ -46,9 +46,14 @@ public class InternalApiClient { // The second alternation carves out `/api/v1/ai/tools/*` specifically — AI tools are // dispatchable, but the broader `/api/v1/ai/` surface (orchestrate, health, etc.) is // intentionally NOT permitted to avoid plan steps re-entering the orchestrator. + // + // `/api/v1/integration/*` holds third-party steps (external API call, Purview labelling, + // ConsignO). They reach outside the JVM, so the namespace is deliberately kept to tools that + // dereference an admin-owned connection rather than a caller-supplied host — see + // ApiConnectionResolver. private static final Pattern ALLOWED_ENDPOINT_PATH = Pattern.compile( - "^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$" + "^/api/v1/(general|misc|security|convert|filter|integration)(/[A-Za-z0-9_-]+)+$" + "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$"); /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java index f160b92759..4be76e174e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -39,6 +39,11 @@ public class SecretMasker { "bearer", "signature"); + // Keys whose nested map holds secrets under arbitrary, caller-named keys - a free-form HTTP + // headers map is the case in point: the secret can sit under any header name (X-API-Key, + // Ocp-Apim-Subscription-Key), so the name is no signal. Mask every value in these outright. + private static final Set SENSITIVE_VALUE_CONTAINERS = Set.of("headers"); + /** Replace sensitive values with the mask (recursively) for safe display. */ public Map mask(Map config) { return mask(config, 0); @@ -73,6 +78,12 @@ public class SecretMasker { if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) { continue; } + if (isSensitiveContainer(e.getKey()) + && e.getValue() instanceof Map m + && depth < MAX_DEPTH) { + out.put(e.getKey(), sanitizeAllValues(castMap(m), depth + 1)); + continue; + } out.put( e.getKey(), e.getValue() instanceof Map m && depth < MAX_DEPTH @@ -100,6 +111,14 @@ public class SecretMasker { } continue; } + if (isSensitiveContainer(key) + && depth < MAX_DEPTH + && stored.get(key) instanceof Map s + && value instanceof Map i) { + // Every value here is a secret, so restore a redacted one from stored per-entry. + out.put(key, mergeAllValues(castMap(s), castMap(i), depth + 1)); + continue; + } if (depth < MAX_DEPTH && stored.get(key) instanceof Map s && value instanceof Map i) { @@ -119,6 +138,9 @@ public class SecretMasker { } return MASK; } + if (isSensitiveContainer(key) && value instanceof Map m && depth < MAX_DEPTH) { + return maskAllValues(castMap(m), depth + 1); + } if (depth >= MAX_DEPTH) { // Too deep to descend; mask containers rather than risk leaking an unmasked secret. return value instanceof Map || value instanceof List ? MASK : value; @@ -141,6 +163,53 @@ public class SecretMasker { return SENSITIVE_HINTS.stream().anyMatch(lower::contains); } + private boolean isSensitiveContainer(String key) { + return SENSITIVE_VALUE_CONTAINERS.contains(key.toLowerCase(Locale.ROOT)); + } + + /** Mask every value in a container map, whatever its keys are named. */ + private Map maskAllValues(Map map, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + Object v = e.getValue(); + if (v == null || (v instanceof String s && s.isBlank())) { + out.put(e.getKey(), v); + } else if (v instanceof Map m && depth < MAX_DEPTH) { + out.put(e.getKey(), maskAllValues(castMap(m), depth + 1)); + } else { + out.put(e.getKey(), MASK); + } + } + return out; + } + + /** Merge a container map treating every entry as a secret, restoring redacted from stored. */ + private Map mergeAllValues( + Map stored, Map incoming, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : incoming.entrySet()) { + if (isRedacted(e.getValue(), depth)) { + if (stored.containsKey(e.getKey())) { + out.put(e.getKey(), stored.get(e.getKey())); + } + } else { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + + /** Drop redacted entries from a container map on create, whatever their keys are named. */ + private Map sanitizeAllValues(Map map, int depth) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + if (!isRedacted(e.getValue(), depth)) { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + /** Blank, the mask placeholder, or any structure that still contains the mask. */ private boolean isRedacted(Object value, int depth) { if (value == null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java new file mode 100644 index 0000000000..0eef442523 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiAuthType.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.integration.api; + +/** + * How an {@link stirling.software.proprietary.integration.model.IntegrationType#API} connection + * authenticates. + */ +public enum ApiAuthType { + /** No credentials; the endpoint is open or authorises by network position. */ + NONE, + /** {@code Authorization: Bearer }. */ + BEARER, + /** {@code Authorization: Basic base64(username:password)}. */ + BASIC, + /** The token in a caller-named header, e.g. {@code X-API-Key: }. */ + HEADER, + /** + * The connection logs in first and reuses the short-lived token it gets back. + * + *

For the large class of enterprise APIs - ConsignO Cloud, OAuth2 client-credentials, and + * others - where credentials buy a token rather than authenticating a call directly. Without + * this a step could not reach them at all: each call needs a token, and a stateless step has + * nowhere to obtain or keep one. See {@link ApiTokenLogin}. + */ + TOKEN_LOGIN +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java new file mode 100644 index 0000000000..e29435ff14 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionResolver.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.integration.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Dereferences a step's {@code connectionId} to a stored integration config. + * + *

Mirrors {@code S3ConnectionResolver}. When an authenticated caller is present the connection + * must be usable by them; a background worker thread carries no {@code SecurityContext} and skips + * that check, relying on the step having been access-checked when the policy was saved or when an + * ad-hoc run was dispatched - see {@link IntegrationStepValidator}, which is what makes that + * assumption true rather than merely hoped for. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ApiConnectionResolver { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository connections; + private final OwnershipService ownership; + private final UserService userService; + + /** The raw config map for a connection of the given type. */ + public Map resolveConfig(Long connectionId, IntegrationType type) { + IntegrationConfig connection = + connections + .findById(connectionId) + .filter(cfg -> cfg.getIntegrationType() == type) + .filter(this::usableByCurrentUser) + // Existence and access collapse into one error so a caller cannot tell + // "no such connection" from "someone else's connection" and enumerate ids. + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown or inaccessible " + + type.name().toLowerCase() + + " connection")); + if (!connection.isEnabled()) { + throw new IllegalArgumentException( + type.name().toLowerCase() + " connection is disabled"); + } + return configOf(connection); + } + + /** The settings for a generic {@code API} connection. */ + public ApiConnectionSettings resolve(Long connectionId) { + return ApiConnectionSettings.from(resolveConfig(connectionId, IntegrationType.API)); + } + + /** Parse a {@code connectionId} step parameter; null when absent. */ + public static Long connectionId(Object reference) { + if (reference == null || (reference instanceof String s && s.isBlank())) { + return null; + } + if (reference instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(reference.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "'connectionId' is not a valid connection reference: " + reference); + } + } + + /** + * Whether the current caller may use this connection. A missing principal means a worker + * thread, where access was established earlier; it must never be the only thing standing + * between a caller and a connection, or the check becomes a confused deputy. + */ + private boolean usableByCurrentUser(IntegrationConfig connection) { + User user = currentUser(); + return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user); + } + + // Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated. + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } + + private static Map configOf(IntegrationConfig connection) { + String json = connection.getConfig(); + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException( + "connection '" + connection.getName() + "' has unreadable config", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java new file mode 100644 index 0000000000..a1bb29cf84 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiConnectionSettings.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A resolved {@code API} connection: where to call, and how to authenticate. + * + *

{@code baseUrl} is the security anchor of the whole feature. It is set by whoever can manage + * the connection (an admin or team owner) and is the only thing that decides which host is + * contacted. A pipeline step supplies a relative path only, resolved under this base by + * {@link ExternalApiPaths}, so a step author can never pivot the call to a host of their choosing. + * Widening that - letting a step pass a full URL - would turn every policy into an SSRF primitive. + * + *

Whether the base URL may resolve to a private address is deliberately not a field + * here. Any user may create an API connection (unlike S3, which {@code IntegrationConfigService} + * restricts to admins), so a per-connection opt-in would let a user grant themselves a fetch of the + * cloud metadata service. It is an operator property instead - {@code + * policies.allowPrivateApiEndpoints} - checked by {@link ApiIntegrationValidator}. + */ +public record ApiConnectionSettings( + String baseUrl, + ApiAuthType authType, + String headerName, + String headerPrefix, + String token, + String username, + String password, + Map headers, + ApiTokenLogin tokenLogin, + Set resultUrlHosts, + int timeoutSeconds) { + + static final String BASE_URL_OPTION = "baseUrl"; + static final String AUTH_TYPE_OPTION = "authType"; + static final String HEADER_NAME_OPTION = "headerName"; + static final String HEADER_PREFIX_OPTION = "headerPrefix"; + // "token"/"password" contain SecretMasker hints, so they mask on read and merge on update. + static final String TOKEN_OPTION = "token"; + static final String USERNAME_OPTION = "username"; + static final String PASSWORD_OPTION = "password"; + static final String HEADERS_OPTION = "headers"; + static final String RESULT_URL_HOSTS_OPTION = "resultUrlHosts"; + static final String TIMEOUT_SECONDS_OPTION = "timeoutSeconds"; + + static final int DEFAULT_TIMEOUT_SECONDS = 60; + private static final int MAX_TIMEOUT_SECONDS = 600; + + public ApiConnectionSettings { + headers = headers == null ? Map.of() : Map.copyOf(headers); + resultUrlHosts = resultUrlHosts == null ? Set.of() : Set.copyOf(resultUrlHosts); + } + + /** + * @throws IllegalArgumentException if the config is unusable; the message is surfaced to the + * operator editing the connection, so it names the offending option. + */ + public static ApiConnectionSettings from(Map options) { + String baseUrl = trimmed(options.get(BASE_URL_OPTION)); + if (baseUrl == null) { + throw new IllegalArgumentException("api config requires a 'baseUrl' option"); + } + URI uri = parseHttpUrl(baseUrl); + if (uri.getQuery() != null || uri.getFragment() != null) { + throw new IllegalArgumentException( + "api config 'baseUrl' must not carry a query string or fragment"); + } + + ApiAuthType authType = parseAuthType(trimmed(options.get(AUTH_TYPE_OPTION))); + String headerName = trimmed(options.get(HEADER_NAME_OPTION)); + // Many APIs want a scheme before the token ("Authorization: Token abc", + // "Authorization: DeepL-Auth-Key abc"). Without this a preset would have to make the + // operator paste the scheme into the secret itself, which reads as a typo waiting to + // happen. + String headerPrefix = trimmed(options.get(HEADER_PREFIX_OPTION)); + String token = trimmed(options.get(TOKEN_OPTION)); + String username = trimmed(options.get(USERNAME_OPTION)); + String password = trimmed(options.get(PASSWORD_OPTION)); + + switch (authType) { + case BEARER -> require(token, "api config authType 'BEARER' requires a 'token'"); + case HEADER -> { + require(token, "api config authType 'HEADER' requires a 'token'"); + require(headerName, "api config authType 'HEADER' requires a 'headerName'"); + if (!ExternalApiHeaders.isValidName(headerName)) { + throw new IllegalArgumentException( + "api config 'headerName' is not a valid HTTP header name: " + + headerName); + } + } + case BASIC -> { + require(username, "api config authType 'BASIC' requires a 'username'"); + require(password, "api config authType 'BASIC' requires a 'password'"); + } + case TOKEN_LOGIN -> { + /* validated by ApiTokenLogin.from below */ + } + case NONE -> { + /* nothing to check */ + } + } + + return new ApiConnectionSettings( + stripTrailingSlash(baseUrl), + authType, + headerName, + headerPrefix, + token, + username, + password, + parseHeaders(options.get(HEADERS_OPTION)), + authType == ApiAuthType.TOKEN_LOGIN ? ApiTokenLogin.from(options) : null, + parseResultUrlHosts(options.get(RESULT_URL_HOSTS_OPTION)), + parseTimeout(options.get(TIMEOUT_SECONDS_OPTION))); + } + + /** The configured base as a URI; callers resolve step paths under it. */ + public URI baseUri() { + return URI.create(baseUrl); + } + + /** + * Identity of this connection's login for token-cache purposes. Includes the credentials, so + * editing a password evicts the token cached under the old one rather than reusing it until it + * expires. + */ + String tokenCacheKey() { + return baseUrl + "|" + Objects.hash(tokenLogin); + } + + private static URI parseHttpUrl(String value) { + URI uri; + try { + uri = new URI(value); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("api config 'baseUrl' is not a valid URL", e); + } + String scheme = uri.getScheme() == null ? null : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new IllegalArgumentException( + "api config 'baseUrl' must be an http(s) URL, e.g. https://api.example.com"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("api config 'baseUrl' must include a host"); + } + return uri; + } + + private static ApiAuthType parseAuthType(String value) { + if (value == null) { + return ApiAuthType.NONE; + } + try { + return ApiAuthType.valueOf(value.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "api config 'authType' must be one of NONE, BEARER, BASIC, HEADER; got " + + value); + } + } + + /** Static headers sent on every call. Rejects anything auth-bearing to keep one auth path. */ + private static Map parseHeaders(Object value) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map raw)) { + throw new IllegalArgumentException("api config 'headers' must be an object"); + } + Map headers = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + String name = trimmed(entry.getKey()); + String headerValue = entry.getValue() == null ? null : entry.getValue().toString(); + if (name == null) { + continue; + } + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api config 'headers' has an invalid header name: " + name); + } + if (ExternalApiHeaders.isReserved(name)) { + throw new IllegalArgumentException( + "api config 'headers' must not set '" + + name + + "'; use 'authType' and 'token' instead"); + } + if (headerValue == null || !ExternalApiHeaders.isValidValue(headerValue)) { + throw new IllegalArgumentException( + "api config 'headers' has an invalid value for '" + name + "'"); + } + headers.put(name, headerValue); + } + return headers; + } + + /** + * Hosts a result may be fetched from, beyond the connection's own. Declared by the operator + * because the alternative - trusting the host named in the API's response - is an SSRF. + */ + private static Set parseResultUrlHosts(Object value) { + if (value == null) { + return Set.of(); + } + if (!(value instanceof java.util.List list)) { + throw new IllegalArgumentException( + "api config 'resultUrlHosts' must be a list of hostnames"); + } + Set out = new java.util.LinkedHashSet<>(); + for (Object entry : list) { + String host = trimmed(entry); + if (host == null) { + continue; + } + if (host.contains("/") || host.contains(":") || host.contains("*")) { + // A URL, port or wildcard here would read as broader than it is; subdomains are + // already covered by the "endsWith('.' + host)" rule at match time. + throw new IllegalArgumentException( + "api config 'resultUrlHosts' takes bare hostnames, e.g." + + " cdn.vendor.com; got " + + host); + } + out.add(host.toLowerCase(Locale.ROOT)); + } + return out; + } + + private static int parseTimeout(Object value) { + if (value == null) { + return DEFAULT_TIMEOUT_SECONDS; + } + int seconds; + try { + seconds = Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("api config 'timeoutSeconds' must be a number"); + } + if (seconds < 1 || seconds > MAX_TIMEOUT_SECONDS) { + throw new IllegalArgumentException( + "api config 'timeoutSeconds' must be between 1 and " + MAX_TIMEOUT_SECONDS); + } + return seconds; + } + + private static void require(String value, String message) { + if (value == null) { + throw new IllegalArgumentException(message); + } + } + + private static String stripTrailingSlash(String value) { + String out = value; + while (out.endsWith("/")) { + out = out.substring(0, out.length() - 1); + } + return out; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the credentials, so an accidental log line cannot leak them. */ + @Override + public String toString() { + return "ApiConnectionSettings[baseUrl=" + + baseUrl + + ", authType=" + + authType + + ", timeoutSeconds=" + + timeoutSeconds + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java new file mode 100644 index 0000000000..014bacb4a5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiIntegrationValidator.java @@ -0,0 +1,109 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** + * The {@code API} connection schema, enforced when the config is saved: an http(s) base URL, a + * coherent auth block, and a host that must not reach private addresses without the operator + * opt-in. + * + *

The host check runs here so a bad connection fails in the form rather than mid-run. It is not + * the only check - {@link ExternalApiCaller} re-checks before dispatch, because DNS can be + * re-pointed at a private address long after save time (a check-then-use gap this validator alone + * cannot close). + */ +@Component +@RequiredArgsConstructor +public class ApiIntegrationValidator implements IntegrationConfigValidator { + + private final ApplicationProperties applicationProperties; + + @Override + public IntegrationType type() { + return IntegrationType.API; + } + + @Override + public void validate(Map config) { + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + requirePublicHost(settings, applicationProperties, "API connection base URL"); + } + + /** + * Shared by every integration type that dials an operator-supplied host, so they cannot drift + * apart on what counts as reachable. + */ + static void requirePublicHost( + ApiConnectionSettings settings, + ApplicationProperties applicationProperties, + String settingName) { + // Block the cloud metadata service unconditionally - before the opt-in check. The private- + // endpoint opt-in exists for on-prem services (RFC1918, an internal gateway), but the + // metadata endpoint is never a real integration and reaching it is the highest-value SSRF: + // it hands out the instance's own IAM credentials. So it stays blocked even when the + // operator has allowed private endpoints. + denyCloudMetadata(settings.baseUri(), settingName); + try { + S3Clients.validateEndpointHost( + settings.baseUri(), + applicationProperties.getPolicies().isAllowPrivateApiEndpoints(), + settingName, + "set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem" + + " integration)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + /** AWS/GCP/Azure, Oracle and IBM metadata addresses; mirrors {@code SsrfProtectionService}. */ + private static final java.util.Set CLOUD_METADATA_IPS = + java.util.Set.of( + "169.254.169.254", "169.254.169.253", "169.254.169.250", "fd00:ec2::254"); + + private static void denyCloudMetadata(java.net.URI uri, String settingName) { + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return; // a missing host is S3Clients' error to report, with its own message + } + java.net.InetAddress[] addresses; + try { + addresses = java.net.InetAddress.getAllByName(host); + } catch (java.net.UnknownHostException e) { + return; // an unresolvable host is likewise left to S3Clients to reject + } + for (java.net.InetAddress address : addresses) { + String ip = normalise(address.getHostAddress()); + if (CLOUD_METADATA_IPS.stream().anyMatch(ip::startsWith)) { + throw new IllegalArgumentException( + settingName + + " host '" + + host + + "' resolves to the cloud metadata service (" + + ip + + "), which is never a valid integration target."); + } + } + } + + /** Strip an IPv4-mapped-IPv6 prefix and any zone id so the compare sees a bare address. */ + private static String normalise(String ip) { + String out = ip; + int zone = out.indexOf('%'); + if (zone >= 0) { + out = out.substring(0, zone); + } + if (out.startsWith("::ffff:")) { + out = out.substring(7); + } + return out; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java new file mode 100644 index 0000000000..3610f77ab0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenCache.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.springframework.http.MediaType; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import lombok.extern.slf4j.Slf4j; + +import tools.jackson.databind.ObjectMapper; + +/** + * Obtains and caches the short-lived tokens of {@link ApiAuthType#TOKEN_LOGIN} connections. + * + *

The step that uses a token is stateless and runs once per document, so without a cache a + * hundred-document policy would perform a hundred logins - which many vendors rate-limit, and some + * treat as suspicious. The cache is keyed on the connection's login identity (credentials included) + * so that editing a password does not keep reusing the token bought with the old one. + * + *

Entries expire well inside the vendor's stated lifetime, and a 401 additionally evicts and + * retries once ({@link ExternalApiCaller}), so a token that expires early - or is revoked - costs + * one retry rather than a failed run. + */ +@Slf4j +public class ApiTokenCache { + + /** Bounded so a deployment with many connections cannot grow this without limit. */ + private static final int MAX_ENTRIES = 500; + + private final Cache tokens; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + ApiTokenCache(HttpClient httpClient, ObjectMapper objectMapper) { + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.tokens = + Caffeine.newBuilder() + .maximumSize(MAX_ENTRIES) + // Per-entry, because each connection states its own lifetime. + .expireAfter( + new com.github.benmanes.caffeine.cache.Expiry() { + @Override + public long expireAfterCreate( + String key, String value, long currentTime) { + return ttlNanos(key); + } + + @Override + public long expireAfterUpdate( + String key, + String value, + long currentTime, + long currentDuration) { + return ttlNanos(key); + } + + @Override + public long expireAfterRead( + String key, + String value, + long currentTime, + long currentDuration) { + // Reading must not extend a token's life: the vendor's + // clock is running regardless of how often we use it. + return currentDuration; + } + }) + .build(); + } + + // The TTL travels in the key so the Expiry callbacks can see it without a second lookup. + private static long ttlNanos(String key) { + int seconds = Integer.parseInt(key.substring(key.lastIndexOf('#') + 1)); + return TimeUnit.SECONDS.toNanos(seconds); + } + + /** + * The connection's current token, logging in if there is not a live one. + * + * @throws IOException if the login call fails or returns no token + */ + String token(ApiConnectionSettings settings) throws IOException { + String key = cacheKey(settings); + String cached = tokens.getIfPresent(key); + if (cached != null) { + return cached; + } + String token = login(settings); + tokens.put(key, token); + return token; + } + + /** Drop the cached token, e.g. after a 401 says it is no longer accepted. */ + void invalidate(ApiConnectionSettings settings) { + tokens.invalidate(cacheKey(settings)); + } + + private static String cacheKey(ApiConnectionSettings settings) { + return settings.tokenCacheKey() + "#" + settings.tokenLogin().tokenTtlSeconds(); + } + + private String login(ApiConnectionSettings settings) throws IOException { + ApiTokenLogin login = settings.tokenLogin(); + URI target = ExternalApiPaths.resolve(settings.baseUri(), login.loginPath()); + + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .POST( + HttpRequest.BodyPublishers.ofByteArray( + objectMapper.writeValueAsBytes(login.loginBody()))); + login.loginHeaders().forEach(request::header); + + ExternalApiCaller.Response response = + ExternalApiCaller.send(httpClient, request.build(), target); + if (!response.isSuccess()) { + // Deliberately does not echo the body: a login failure response can repeat the + // credentials back, and this message reaches the run log. + throw new IOException( + "Login to " + + target.getHost() + + login.loginPath() + + " returned HTTP " + + response.status()); + } + try { + String token = login.extractToken(response, objectMapper); + log.debug("[external-api] obtained a token from {}", target.getHost()); + return token; + } catch (IllegalStateException e) { + throw new IOException(e.getMessage(), e); + } + } + + /** The auth header for an authenticated call. */ + Map.Entry authHeader(ApiConnectionSettings settings) throws IOException { + return settings.tokenLogin().authHeader(token(settings)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java new file mode 100644 index 0000000000..a7c02d786f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ApiTokenLogin.java @@ -0,0 +1,209 @@ +package stirling.software.proprietary.integration.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * How a connection turns credentials into a short-lived token. + * + *

Modelled on what real APIs actually do rather than on one vendor. The two axes that vary are + * where the token comes back ({@code tokenResponseHeader} or {@code tokenResponseJsonPath}) and how + * it is then presented ({@code tokenHeaderName} + {@code tokenPrefix}). That covers both ends of + * the spectrum: + * + *

    + *
  • ConsignO Cloud - {@code POST /auth/login} with {@code X-Client-Id}/{@code X-Client-Secret} + * headers and a {@code {username, password, tenantId}} body, returning the token in the + * {@code X-Auth-Token} response header, which is then sent back as {@code + * X-Auth-Token}. + *
  • OAuth2 client-credentials - a form or JSON post returning {@code {"access_token": ...}} in + * the body, sent back as {@code Authorization: Bearer ...}. + *
+ * + *

{@code loginBody} and {@code loginHeaders} are stored as nested maps rather than a + * pre-rendered JSON string so {@code SecretMasker} can recurse and mask the {@code password} / + * {@code X-Client-Secret} entries inside them. A flat string would sail past it and hand the + * password back in plaintext on every read of the connection. + */ +record ApiTokenLogin( + String loginPath, + Map loginBody, + Map loginHeaders, + String tokenResponseHeader, + String tokenResponseJsonPath, + String tokenHeaderName, + String tokenPrefix, + int tokenTtlSeconds) { + + static final String LOGIN_PATH_OPTION = "loginPath"; + static final String LOGIN_BODY_OPTION = "loginBody"; + static final String LOGIN_HEADERS_OPTION = "loginHeaders"; + static final String TOKEN_RESPONSE_HEADER_OPTION = "tokenResponseHeader"; + static final String TOKEN_RESPONSE_JSON_PATH_OPTION = "tokenResponseJsonPath"; + static final String TOKEN_HEADER_NAME_OPTION = "tokenHeaderName"; + static final String TOKEN_PREFIX_OPTION = "tokenPrefix"; + static final String TOKEN_TTL_SECONDS_OPTION = "tokenTtlSeconds"; + + /** + * Conservative default. ConsignO's token lasts 30 minutes; caching for 25 leaves room for a + * slow call to finish on a token that was still valid when it started. A cache that expired + * exactly on the vendor's boundary would fail intermittently and look like a network fault. + */ + static final int DEFAULT_TOKEN_TTL_SECONDS = 1500; + + private static final int MAX_TOKEN_TTL_SECONDS = 86400; + + ApiTokenLogin { + loginBody = loginBody == null ? Map.of() : Map.copyOf(loginBody); + loginHeaders = loginHeaders == null ? Map.of() : Map.copyOf(loginHeaders); + } + + static ApiTokenLogin from(Map options) { + String loginPath = trimmed(options.get(LOGIN_PATH_OPTION)); + if (loginPath == null) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' requires a 'loginPath', e.g. /auth/login"); + } + String responseHeader = trimmed(options.get(TOKEN_RESPONSE_HEADER_OPTION)); + String responseJsonPath = trimmed(options.get(TOKEN_RESPONSE_JSON_PATH_OPTION)); + if ((responseHeader == null) == (responseJsonPath == null)) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' needs exactly one of" + + " 'tokenResponseHeader' (e.g. X-Auth-Token) or" + + " 'tokenResponseJsonPath' (e.g. access_token) to say where the token" + + " comes back"); + } + String tokenHeaderName = trimmed(options.get(TOKEN_HEADER_NAME_OPTION)); + if (tokenHeaderName == null) { + throw new IllegalArgumentException( + "api config authType 'TOKEN_LOGIN' requires a 'tokenHeaderName' saying which" + + " header carries the token back, e.g. X-Auth-Token or Authorization"); + } + if (!ExternalApiHeaders.isValidName(tokenHeaderName)) { + throw new IllegalArgumentException( + "api config 'tokenHeaderName' is not a valid HTTP header name: " + + tokenHeaderName); + } + if (responseHeader != null && !ExternalApiHeaders.isValidName(responseHeader)) { + throw new IllegalArgumentException( + "api config 'tokenResponseHeader' is not a valid HTTP header name: " + + responseHeader); + } + + return new ApiTokenLogin( + loginPath, + nestedObject(options.get(LOGIN_BODY_OPTION), LOGIN_BODY_OPTION), + loginHeaders(options.get(LOGIN_HEADERS_OPTION)), + responseHeader, + responseJsonPath, + tokenHeaderName, + trimmed(options.get(TOKEN_PREFIX_OPTION)) == null + ? "" + : trimmed(options.get(TOKEN_PREFIX_OPTION)) + " ", + ttl(options.get(TOKEN_TTL_SECONDS_OPTION))); + } + + /** Pull the token out of a login response. */ + String extractToken(ExternalApiCaller.Response response, ObjectMapper objectMapper) { + if (tokenResponseHeader != null) { + String value = response.header(tokenResponseHeader); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "Login succeeded but returned no '" + + tokenResponseHeader + + "' response header"); + } + return value; + } + JsonNode node = response.bodyAsJson(objectMapper); + for (String segment : tokenResponseJsonPath.split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.isValueNode() || node.asString().isBlank()) { + throw new IllegalStateException( + "Login succeeded but its body had no token at '" + tokenResponseJsonPath + "'"); + } + return node.asString(); + } + + /** The header to send on an authenticated call. */ + Map.Entry authHeader(String token) { + return Map.entry(tokenHeaderName, tokenPrefix + token); + } + + private static Map nestedObject(Object value, String option) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map raw)) { + throw new IllegalArgumentException("api config '" + option + "' must be an object"); + } + Map out = new LinkedHashMap<>(); + raw.forEach((key, entry) -> out.put(String.valueOf(key), entry)); + return out; + } + + private static Map loginHeaders(Object value) { + Map out = new LinkedHashMap<>(); + nestedObject(value, LOGIN_HEADERS_OPTION) + .forEach( + (name, entry) -> { + String headerValue = entry == null ? null : entry.toString(); + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api config 'loginHeaders' has an invalid header name: " + + name); + } + if (headerValue == null + || !ExternalApiHeaders.isValidValue(headerValue)) { + throw new IllegalArgumentException( + "api config 'loginHeaders' has an invalid value for '" + + name + + "'"); + } + out.put(name, headerValue); + }); + return out; + } + + private static int ttl(Object value) { + if (value == null) { + return DEFAULT_TOKEN_TTL_SECONDS; + } + int seconds; + try { + seconds = Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("api config 'tokenTtlSeconds' must be a number"); + } + if (seconds < 1 || seconds > MAX_TOKEN_TTL_SECONDS) { + throw new IllegalArgumentException( + "api config 'tokenTtlSeconds' must be between 1 and " + MAX_TOKEN_TTL_SECONDS); + } + return seconds; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the login body or headers: both carry the credentials. */ + @Override + public String toString() { + return "ApiTokenLogin[loginPath=" + + loginPath + + ", tokenTtlSeconds=" + + tokenTtlSeconds + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java new file mode 100644 index 0000000000..044d24c0c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/DocumentContext.java @@ -0,0 +1,180 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.Calendar; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Everything Stirling already knows about the document and the run, as one JSON object. + * + *

An external API almost always wants more than the bytes: what the file is, what it was called, + * whether it is already classified or labelled, and which policy sent it. All of that is in hand at + * the moment of the call, so it is offered rather than left for the operator to re-derive - most + * usefully the Purview label and the classifier's verdict, which turn a call-out into something the + * receiving system can make a decision with. + * + *

The shape is also the namespace for placeholders (see {@link Placeholders}), so {@code + * {{document.sha256}}} or {@code {{sensitivityLabel.name}}} in a field, path, or header resolves + * against exactly what is documented here: + * + *

+ * document.filename | .extension | .contentType | .sizeBytes | .sha256 | .base64
+ *         .pageCount | .encrypted | .title | .author | .subject | .keywords
+ *         .creator | .producer | .created | .modified
+ * classification.*         the classifier policy's verdict, when it has run
+ * sensitivityLabel.labelId | .name | .siteId | .method | .protected
+ * run.policyName | .runId | .timestamp
+ * 
+ * + *

Every field is best-effort: a non-PDF, an unparseable PDF, or an ad-hoc run with no policy + * simply omits what it cannot know. Building the context must never be the reason a step fails. + */ +@Slf4j +final class DocumentContext { + + private DocumentContext() {} + + static ObjectNode build( + MultipartFile file, + byte[] content, + String policyName, + String runId, + ObjectMapper objectMapper) { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + + String filename = file.getOriginalFilename(); + document.put("filename", filename); + document.put("extension", extensionOf(filename)); + document.put("contentType", file.getContentType()); + document.put("sizeBytes", content.length); + document.put("sha256", sha256(content)); + // The bytes themselves, for steps that carry the document inside a JSON body + // (an attachment field, a signing payload) rather than as multipart. + document.put("base64", Base64.getEncoder().encodeToString(content)); + + if (looksLikePdf(content)) { + addPdfFacts(document, root, content, objectMapper); + } + + ObjectNode run = root.putObject("run"); + run.put("policyName", policyName); + run.put("runId", runId); + run.put("timestamp", Instant.now().toString()); + return root; + } + + /** PDF-only facts. A document we cannot parse still gets the basics above. */ + private static void addPdfFacts( + ObjectNode document, ObjectNode root, byte[] content, ObjectMapper objectMapper) { + try (PDDocument pdf = Loader.loadPDF(content)) { + document.put("pageCount", pdf.getNumberOfPages()); + document.put("encrypted", pdf.isEncrypted()); + + PDDocumentInformation info = pdf.getDocumentInformation(); + document.put("title", info.getTitle()); + document.put("author", info.getAuthor()); + document.put("subject", info.getSubject()); + document.put("keywords", info.getKeywords()); + document.put("creator", info.getCreator()); + document.put("producer", info.getProducer()); + document.put("created", toIso(info.getCreationDate())); + document.put("modified", toIso(info.getModificationDate())); + + addClassification(root, info, objectMapper); + addSensitivityLabel(root, pdf); + } catch (IOException | RuntimeException e) { + // An encrypted or malformed PDF is a normal thing to send to an external API; the + // extra facts are a convenience, not a precondition. + log.debug("Could not read PDF facts for the step context: {}", e.getMessage()); + } + } + + /** The classifier policy's verdict, so a call-out can act on it without re-classifying. */ + private static void addClassification( + ObjectNode root, PDDocumentInformation info, ObjectMapper objectMapper) { + String raw = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY); + if (raw == null || raw.isBlank()) { + return; + } + try { + JsonNode parsed = objectMapper.readTree(raw); + root.set("classification", parsed); + } catch (RuntimeException e) { + // Written by another tool; if it is not JSON, pass it through as text rather than drop + // it - the receiving system may still recognise it. + root.put("classification", raw); + } + } + + /** The Purview label already on the document, if any. */ + private static void addSensitivityLabel(ObjectNode root, PDDocument pdf) { + List labels = PdfSensitivityLabels.readAll(pdf); + if (labels.isEmpty()) { + return; + } + SensitivityLabel label = labels.get(0); + ObjectNode node = root.putObject("sensitivityLabel"); + node.put("labelId", label.labelId()); + node.put("name", label.name()); + node.put("siteId", label.siteId()); + node.put("method", label.method() == null ? null : label.method().name()); + node.put("protected", label.isProtected()); + } + + /** Cheap check so a non-PDF never pays for a parse attempt. */ + private static boolean looksLikePdf(byte[] content) { + return content.length > 4 + && content[0] == '%' + && content[1] == 'P' + && content[2] == 'D' + && content[3] == 'F'; + } + + /** + * A content hash is the field external systems most often key on - dedupe, chain-of-custody, + * "have I already scanned this" - and they cannot compute it without the bytes we are sending. + */ + private static String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required by the Java platform", e); + } + } + + private static String toIso(Calendar calendar) { + return calendar == null ? null : calendar.toInstant().toString(); + } + + private static String extensionOf(String filename) { + if (filename == null) { + return null; + } + int dot = filename.lastIndexOf('.'); + return dot < 0 || dot == filename.length() - 1 + ? null + : filename.substring(dot + 1).toLowerCase(Locale.ROOT); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java new file mode 100644 index 0000000000..499ab90fdb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCallController.java @@ -0,0 +1,552 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.AutomationRunContext; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Posts the document flowing through a policy to a third-party HTTP API and folds the answer back + * into the run. + * + *

This is the generic integration step: rather than a bespoke connector per vendor, an operator + * defines an {@code API} connection (base URL + credentials) once and any policy can call a path + * under it. The connection owns the host and the credentials; the step owns only the path and the + * form fields, so a policy author can never aim the call somewhere else or read the secret. + * + *

Response handling is explicit rather than inferred, because the two useful behaviours destroy + * different things when guessed wrong: + * + *

    + *
  • {@code report} (default) - the document continues untouched and the API's answer rides + * along in {@link AiToolResponseHeaders#TOOL_REPORT}. For call-outs that inspect or notify. A + * {@code requireTrue} field turns the answer into a gate: the named JSON verdict must be true + * or the step fails, so a scanner's "not clean" actually stops the run. + *
  • {@code replace} - the response body becomes the document. For call-outs that + * transform. Fails loudly if the API returns JSON or an empty body, instead of silently + * dropping the document from the pipeline. + *
+ */ +@Slf4j +@RestController +@RequestMapping("/api/v1/integration") +@RequiredArgsConstructor +@Tag(name = "Integrations", description = "Third-party integration steps.") +public class ExternalApiCallController { + + static final String MODE_REPORT = "report"; + static final String MODE_REPLACE = "replace"; + + /** + * The report travels as an HTTP header, and Jetty caps a response header at 8KB by default. A + * body larger than this is summarised rather than risking a header the container refuses to + * write - which would fail the whole step over a merely verbose API. + */ + static final int MAX_REPORT_BODY_CHARS = 4096; + + static final String BODY_MULTIPART = "multipart"; + static final String BODY_JSON = "json"; + static final String BODY_BINARY = "binary"; + + /** Field (multipart) and property (json) the auto-populated context is offered under. */ + static final String CONTEXT_FIELD = "stirlingContext"; + + private final ApiConnectionResolver connectionResolver; + private final ExternalApiCaller caller; + private final ObjectMapper objectMapper; + private final TempFileManager tempFileManager; + private final ApplicationProperties applicationProperties; + + @PostMapping(value = "/external-api-call", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Send the document to an external API", + description = + "Sends the document to a path under a stored API connection's base URL and" + + " either records the response as a step report or replaces the" + + " document with it. Fields, path and headers may reference" + + " {{document.*}}, {{classification.*}}, {{sensitivityLabel.*}} and" + + " {{run.*}}. Type:SISO") + public ResponseEntity call( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId, + @RequestParam(value = "path", required = false) String path, + @RequestParam(value = "method", defaultValue = "POST") String method, + @RequestParam(value = "bodyMode", defaultValue = BODY_MULTIPART) String bodyMode, + @RequestParam(value = "fileFieldName", defaultValue = "file") String fileFieldName, + @RequestParam(value = "responseMode", defaultValue = MODE_REPORT) String responseMode, + @RequestParam(value = "resultUrlPath", required = false) String resultUrlPath, + @RequestParam(value = "resultUrlHeader", required = false) String resultUrlHeader, + @RequestParam(value = "responseSelect", required = false) String responseSelect, + @RequestParam(value = "requireTrue", required = false) String requireTrue, + @RequestParam(value = "fields", required = false) String fields, + @RequestParam(value = "bodyTemplate", required = false) String bodyTemplate, + @RequestParam(value = "headers", required = false) String headers, + @RequestParam(value = "includeContext", defaultValue = "false") boolean includeContext, + @RequestParam(value = "includeFile", defaultValue = "true") boolean includeFile, + @RequestHeader(value = InternalApiClient.POLICY_NAME_HEADER, required = false) + String policyName, + @RequestHeader(value = AutomationRunContext.RUN_ID_HEADER, required = false) + String runId) + throws IOException { + + String mode = normalise(responseMode, MODE_REPORT, MODE_REPORT, MODE_REPLACE); + String body = normalise(bodyMode, BODY_MULTIPART, BODY_MULTIPART, BODY_JSON, BODY_BINARY); + String verb = parseMethod(method); + + Long id = ApiConnectionResolver.connectionId(connectionId); + if (id == null) { + throw new IllegalArgumentException("'connectionId' is required"); + } + ApiConnectionSettings settings = connectionResolver.resolve(id); + + String filename = safeFileName(fileInput.getOriginalFilename()); + String contentType = + fileInput.getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : fileInput.getContentType(); + byte[] content = fileInput.getBytes(); + + ObjectNode context = + DocumentContext.build(fileInput, content, policyName, runId, objectMapper); + + ExternalApiCaller.Response response = + caller.dispatch( + settings, + verb, + Placeholders.resolve(path, context, Placeholders.Escaping.URL_PATH), + buildBody( + body, + bodyTemplate, + includeFile, + includeContext, + context, + fileFieldName, + filename, + contentType, + content, + resolveAll(parseJsonObject(fields, "fields"), context)), + validatedHeaders(resolveAll(parseJsonObject(headers, "headers"), context))); + + if (!response.isSuccess()) { + // Fail the step: a policy that silently continued past a rejected call-out would + // deliver documents the external system believes it never approved. + throw new IOException( + "External API returned HTTP " + response.status() + summarise(response)); + } + + enforceVerdict(response, requireTrue); + + return MODE_REPLACE.equals(mode) + ? replaceDocument( + settings, + response, + filename, + resultUrlPath, + resultUrlHeader, + responseSelect) + : reportOnly(fileInput, filename, contentType, response); + } + + /** + * Assemble the outbound body. + * + *
    + *
  • {@code multipart} - the file plus form fields, what most upload APIs expect. + *
  • {@code json} - a JSON object of the fields, with the context merged in and the file + * base64'd under {@code content}. For APIs that take a document as JSON, and for + * notify-style call-outs (with {@code includeFile=false}) that want the facts only. + *
  • {@code binary} - the raw bytes as the body. For APIs that want the file and nothing + * else; fields would have nowhere to go, so they are refused rather than dropped. + *
+ */ + private ExternalApiCaller.Body buildBody( + String bodyMode, + String bodyTemplate, + boolean includeFile, + boolean includeContext, + ObjectNode context, + String fileFieldName, + String filename, + String contentType, + byte[] content, + Map fields) + throws IOException { + + if (bodyTemplate != null && !bodyTemplate.isBlank()) { + return templatedBody(bodyTemplate, context, filename, contentType, content); + } + switch (bodyMode) { + case BODY_BINARY -> { + if (!fields.isEmpty()) { + throw new IllegalArgumentException( + "bodyMode 'binary' sends only the document, so 'fields' cannot be" + + " sent; use 'headers' instead, or bodyMode 'multipart'."); + } + if (!includeFile) { + throw new IllegalArgumentException( + "bodyMode 'binary' with includeFile=false would send an empty body"); + } + return ExternalApiCaller.raw(contentType, content); + } + case BODY_JSON -> { + ObjectNode json = objectMapper.createObjectNode(); + fields.forEach(json::put); + if (includeContext) { + json.setAll(context); + } + if (includeFile) { + json.put("filename", filename); + json.put("contentType", contentType); + json.put("content", Base64.getEncoder().encodeToString(content)); + } + return ExternalApiCaller.raw( + MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(json)); + } + default -> { + Map all = new LinkedHashMap<>(fields); + if (includeContext) { + all.put(CONTEXT_FIELD, objectMapper.writeValueAsString(context)); + } + if (!includeFile) { + // Fields-only multipart: a notify-style call-out that wants the facts, not + // the document. + MultipartBody body = new MultipartBody(); + body.addFields(all); + return new ExternalApiCaller.Body(body.contentType(), body.build()); + } + return ExternalApiCaller.multipart( + fileFieldName, filename, contentType, content, all); + } + } + } + + /** + * A caller-shaped JSON body: the template is resolved against the context, so an arbitrary + * vendor payload can be expressed as config. {@code {{document.base64}}} carries the file + * itself, which is how APIs that take a document nested inside a JSON document are reached. + * + *

The base64 is added to a copy of the context rather than the context proper: it is the + * size of the file, and {@code stirlingContext} must not silently grow by a whole document. + */ + private ExternalApiCaller.Body templatedBody( + String bodyTemplate, + ObjectNode context, + String filename, + String contentType, + byte[] content) + throws IOException { + JsonNode template; + try { + template = objectMapper.readTree(bodyTemplate); + } catch (Exception e) { + throw new IllegalArgumentException("api step 'bodyTemplate' must be valid JSON", e); + } + ObjectNode withFile = context.deepCopy(); + ObjectNode document = (ObjectNode) withFile.get("document"); + if (document != null) { + document.put("base64", Base64.getEncoder().encodeToString(content)); + document.put("safeFilename", filename); + document.put("resolvedContentType", contentType); + } + JsonNode resolved = Placeholders.resolveTree(template, withFile); + return ExternalApiCaller.raw( + MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(resolved)); + } + + /** Resolve every value's placeholders against the context. */ + private Map resolveAll(Map values, ObjectNode context) { + Map out = new LinkedHashMap<>(); + values.forEach( + (key, value) -> + out.put( + key, + Placeholders.resolve(value, context, Placeholders.Escaping.NONE))); + return out; + } + + /** Per-step headers, held to the same rules as a connection's static headers. */ + private Map validatedHeaders(Map headers) { + headers.forEach( + (name, value) -> { + if (!ExternalApiHeaders.isValidName(name)) { + throw new IllegalArgumentException( + "api step 'headers' has an invalid header name: " + name); + } + if (ExternalApiHeaders.isReserved(name)) { + throw new IllegalArgumentException( + "api step 'headers' must not set '" + + name + + "'; it is set by the connection or the client"); + } + if (!ExternalApiHeaders.isValidValue(value)) { + // A resolved placeholder could carry a newline out of document metadata. + throw new IllegalArgumentException( + "api step 'headers' has an invalid value for '" + name + "'"); + } + }); + return headers; + } + + private static String parseMethod(String method) { + String verb = method == null ? "POST" : method.trim().toUpperCase(Locale.ROOT); + // Only the verbs that carry a body; GET/DELETE would silently drop the document. + if (!List.of("POST", "PUT", "PATCH").contains(verb)) { + throw new IllegalArgumentException( + "'method' must be POST, PUT or PATCH; got " + method); + } + return verb; + } + + private static String normalise(String value, String fallback, String... allowed) { + String out = + value == null || value.isBlank() ? fallback : value.trim().toLowerCase(Locale.ROOT); + if (!List.of(allowed).contains(out)) { + throw new IllegalArgumentException( + "must be one of " + String.join(", ", allowed) + "; got " + value); + } + return out; + } + + /** + * Turn the response into the document that continues down the pipeline. + * + *

Three shapes of answer are accepted, because real APIs use all three: the document inline, + * a URL to fetch it from, or an archive to pick it out of. Anything else fails the step rather + * than putting a non-document into the pipeline for a later step to trip over. + */ + private ResponseEntity replaceDocument( + ApiConnectionSettings settings, + ExternalApiCaller.Response response, + String requestFilename, + String resultUrlPath, + String resultUrlHeader, + String responseSelect) + throws IOException { + + ExternalApiCaller.Response payload = response; + boolean followed = false; + String url = resultUrl(response, resultUrlPath, resultUrlHeader); + if (url != null) { + // The URL came out of the response, so ResultUrls decides whether it may be fetched. + payload = + caller.getResult( + settings, ResultUrls.validate(settings, url, applicationProperties)); + followed = true; + if (!payload.isSuccess()) { + throw new IOException( + "Fetching the API's result URL returned HTTP " + payload.status()); + } + } + + if (payload.body().length == 0) { + throw new IOException( + "External API returned an empty body, so there is no document to replace with;" + + " use responseMode=report to keep the original."); + } + if (payload.isJson() && !followed) { + throw new IOException( + "External API returned JSON, which cannot replace the document. Use" + + " responseMode=report to keep the original and record the answer, or" + + " set resultUrlPath if the JSON points at the document."); + } + + String filename = ResultFiles.nameFor(payload, requestFilename); + Resource result = ResultFiles.asResource(payload.body(), filename); + + if (ResultFiles.isArchive(result)) { + if (responseSelect == null || responseSelect.isBlank()) { + // Handing a .zip to a step that expects a PDF fails later and more obscurely. + throw new IOException( + "External API returned an archive; set 'responseSelect' (e.g. *.pdf, or an" + + " index) to say which entry becomes the document."); + } + result = ResultFiles.selectFromArchive(result, responseSelect, tempFileManager); + filename = result.getFilename(); + } else if (responseSelect != null && !responseSelect.isBlank()) { + throw new IOException( + "'responseSelect' was set but the API returned a single file, not an archive"); + } + + MediaType type = + payload.contentType() == null || ResultFiles.isArchiveName(filename) + ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(payload.contentType().split(";")[0].trim()); + return ResponseEntity.ok() + .contentType(type) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"") + .body(result); + } + + /** The result URL the API pointed at, from the body or a header; null when neither is set. */ + private String resultUrl( + ExternalApiCaller.Response response, String resultUrlPath, String resultUrlHeader) { + if (resultUrlHeader != null && !resultUrlHeader.isBlank()) { + String value = response.header(resultUrlHeader.trim()); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException( + "'resultUrlHeader' names '" + + resultUrlHeader + + "' but the response had no such header"); + } + return value; + } + if (resultUrlPath == null || resultUrlPath.isBlank()) { + return null; + } + JsonNode node = response.bodyAsJson(objectMapper); + for (String segment : resultUrlPath.trim().split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.isValueNode() || node.asString().isBlank()) { + throw new IllegalArgumentException( + "'resultUrlPath' found no URL at '" + resultUrlPath + "' in the response"); + } + return node.asString(); + } + + /** + * Gate the run on a boolean verdict in the API's JSON answer (e.g. Cloudmersive's {@code + * CleanResult}). When {@code requireTrue} names a field - dotted for a nested one - that field + * must be JSON {@code true}, or the step fails so the document is parked rather than delivered. + * Fail-closed: a missing field, a non-boolean, a false, or a non-JSON body all stop the run. + * This is what makes a scanner's "not clean" actually stop the pipeline. + */ + private void enforceVerdict(ExternalApiCaller.Response response, String requireTrue) + throws IOException { + if (requireTrue == null || requireTrue.isBlank()) { + return; + } + JsonNode node = response.isJson() ? response.bodyAsJson(objectMapper) : null; + for (String segment : requireTrue.trim().split("\\.")) { + if (node == null) { + break; + } + node = node.get(segment); + } + if (node == null || !node.asBoolean(false)) { + throw new IOException( + "External API verdict '" + + requireTrue.trim() + + "' was not true" + + summarise(response) + + "; the document was not approved, so the run was stopped."); + } + } + + /** The document passes through; the API's answer rides in the report header. */ + private ResponseEntity reportOnly( + MultipartFile fileInput, + String filename, + String contentType, + ExternalApiCaller.Response response) + throws IOException { + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"") + .header(AiToolResponseHeaders.TOOL_REPORT, buildReport(response)) + .body(new ByteArrayResource(fileInput.getBytes())); + } + + /** A JSON object describing the call, small enough to survive as a header. */ + private String buildReport(ExternalApiCaller.Response response) { + ObjectNode report = objectMapper.createObjectNode(); + report.put("status", response.status()); + report.put("contentType", response.contentType()); + if (response.isJson()) { + try { + JsonNode parsed = objectMapper.readTree(response.bodyAsText()); + String rendered = objectMapper.writeValueAsString(parsed); + if (rendered.length() <= MAX_REPORT_BODY_CHARS) { + report.set("body", parsed); + } else { + report.put("bodyTruncated", true); + report.put("body", rendered.substring(0, MAX_REPORT_BODY_CHARS)); + } + } catch (Exception e) { + // Content-Type said JSON but the body is not; keep the step alive and say so. + report.put("bodyParseError", e.getMessage()); + report.put("body", truncate(response.bodyAsText())); + } + } else { + report.put("bodyBytes", response.body().length); + } + return objectMapper.writeValueAsString(report); + } + + /** A JSON object of string values, e.g. {@code {"policy":"strict"}}. */ + private Map parseJsonObject(String json, String what) { + if (json == null || json.isBlank()) { + return Map.of(); + } + Map raw; + try { + raw = + objectMapper.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException("api step '" + what + "' must be a JSON object", e); + } + Map out = new LinkedHashMap<>(); + raw.forEach((key, value) -> out.put(key, value == null ? "" : value.toString())); + return out; + } + + private String summarise(ExternalApiCaller.Response response) { + String text = truncate(response.bodyAsText()); + return text.isBlank() ? "" : ": " + text; + } + + private static String truncate(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replaceAll("\\s+", " ").trim(); + return oneLine.length() <= MAX_REPORT_BODY_CHARS + ? oneLine + : oneLine.substring(0, MAX_REPORT_BODY_CHARS) + "…"; + } + + private static String safeFileName(String originalFilename) { + String name = Filenames.toSimpleFileName(originalFilename); + return (name == null || name.isBlank()) ? "document" : name; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java new file mode 100644 index 0000000000..67171bb5f5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiCaller.java @@ -0,0 +1,299 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Performs the outbound call for an {@code API} connection. + * + *

Follows the established self-hosted outbound pattern (JDK {@link HttpClient}; see {@code + * AccountLinkClient}): the client is injectable so tests can drive a real local server without + * reaching the network. + */ +@Slf4j +@Service +public class ExternalApiCaller { + + /** + * Cap on a response we will read into memory. An external API returning something enormous is a + * misconfiguration, and without a cap it would be a trivial way to OOM the server. + */ + static final int MAX_RESPONSE_BYTES = 64 * 1024 * 1024; + + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + + private final HttpClient httpClient; + private final ApplicationProperties applicationProperties; + private final ApiTokenCache tokenCache; + + @Autowired + public ExternalApiCaller( + ApplicationProperties applicationProperties, ObjectMapper objectMapper) { + this( + HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + // Following a redirect would re-target the request at a host the base URL + // never authorised, undoing ExternalApiPaths. Let the caller see the 3xx. + .followRedirects(HttpClient.Redirect.NEVER) + .build(), + applicationProperties, + objectMapper); + } + + ExternalApiCaller( + HttpClient httpClient, + ApplicationProperties applicationProperties, + ObjectMapper objectMapper) { + this.httpClient = httpClient; + this.applicationProperties = applicationProperties; + this.tokenCache = new ApiTokenCache(httpClient, objectMapper); + } + + /** What the external API sent back, before the step decides what to do with it. */ + public record Response( + int status, String contentType, byte[] body, Map headers) { + + public Response { + headers = headers == null ? Map.of() : Map.copyOf(headers); + } + + /** A response header by name, case-insensitively; null when absent. */ + public String header(String name) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + JsonNode bodyAsJson(ObjectMapper objectMapper) { + try { + return objectMapper.readTree(bodyAsText()); + } catch (RuntimeException e) { + return null; + } + } + + public boolean isSuccess() { + return status >= 200 && status < 300; + } + + public boolean isJson() { + return contentType != null && contentType.toLowerCase().contains("json"); + } + + public String bodyAsText() { + return new String(body, StandardCharsets.UTF_8); + } + } + + /** + * POST a document to {@code path} under the connection's base URL as multipart/form-data. + * + * @throws IOException on transport failure or an oversized response + */ + public Response postFile( + ApiConnectionSettings settings, + String path, + String fileFieldName, + String filename, + String fileContentType, + byte[] content, + Map fields) + throws IOException { + return dispatch( + settings, + "POST", + path, + multipart(fileFieldName, filename, fileContentType, content, fields), + Map.of()); + } + + /** A request body plus the Content-Type that describes it. */ + record Body(String contentType, HttpRequest.BodyPublisher publisher) {} + + static Body multipart( + String fileFieldName, + String filename, + String fileContentType, + byte[] content, + Map fields) + throws IOException { + MultipartBody body = new MultipartBody(); + body.addFields(fields); + body.addFile(fileFieldName, filename, fileContentType, content); + return new Body(body.contentType(), body.build()); + } + + /** A body of caller-built bytes, e.g. a JSON document or the raw file. */ + static Body raw(String contentType, byte[] content) { + return new Body(contentType, HttpRequest.BodyPublishers.ofByteArray(content)); + } + + /** + * Send {@code body} to {@code path} under the connection's base URL. + * + * @param method POST, PUT or PATCH - the verbs that carry a body + * @param extraHeaders per-step headers, already validated by the caller + */ + public Response dispatch( + ApiConnectionSettings settings, + String method, + String path, + Body body, + Map extraHeaders) + throws IOException { + + URI target = ExternalApiPaths.resolve(settings.baseUri(), path); + // Re-check at dispatch: save-time validation cannot see a DNS record re-pointed at a + // private address afterwards. + ApiIntegrationValidator.requirePublicHost( + settings, applicationProperties, "API connection base URL"); + + Response response = attempt(settings, method, target, body, extraHeaders); + if (response.status() == 401 && settings.authType() == ApiAuthType.TOKEN_LOGIN) { + // The cached token was rejected - expired early, or revoked. One fresh login and + // one retry; if that also 401s the credentials are wrong and the step says so. + log.debug("[external-api] token rejected by {}; re-authenticating", target.getHost()); + tokenCache.invalidate(settings); + response = attempt(settings, method, target, body, extraHeaders); + } + return response; + } + + private Response attempt( + ApiConnectionSettings settings, + String method, + URI target, + Body body, + Map extraHeaders) + throws IOException { + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .header("Content-Type", body.contentType()) + .method(method, body.publisher()); + applyHeaders(request, settings); + // Step headers last so a step can override a connection default, but never the auth + // header: ExternalApiHeaders rejects reserved names before we get here. + extraHeaders.forEach(request::header); + return send(httpClient, request.build(), target); + } + + /** + * GET an absolute result URL the API pointed us at. + * + *

Takes a {@link URI} rather than a string so it cannot be called with something unchecked: + * the only way to obtain one is {@link ResultUrls#validate}, which is where the host allowlist + * lives. Credentials are deliberately not sent - the URL is usually a presigned link on another + * host, and forwarding the connection's token there would leak it to a third party. + */ + public Response getResult(ApiConnectionSettings settings, URI target) throws IOException { + HttpRequest request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .GET() + .build(); + return send(httpClient, request, target); + } + + /** GET {@code path} under the connection's base URL. */ + public Response get(ApiConnectionSettings settings, String path) throws IOException { + URI target = ExternalApiPaths.resolve(settings.baseUri(), path); + ApiIntegrationValidator.requirePublicHost( + settings, applicationProperties, "API connection base URL"); + + HttpRequest.Builder request = + HttpRequest.newBuilder(target) + .timeout(Duration.ofSeconds(settings.timeoutSeconds())) + .GET(); + applyHeaders(request, settings); + return send(httpClient, request.build(), target); + } + + static Response send(HttpClient httpClient, HttpRequest request, URI target) + throws IOException { + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted calling " + safeTarget(target), e); + } catch (IOException e) { + // The message can carry the host but never the credentials, which live in headers. + throw new IOException( + "Failed to call " + safeTarget(target) + ": " + e.getMessage(), e); + } + byte[] body = response.body() == null ? new byte[0] : response.body(); + if (body.length > MAX_RESPONSE_BYTES) { + throw new IOException( + "Response from " + + safeTarget(target) + + " exceeds the " + + MAX_RESPONSE_BYTES + + " byte limit"); + } + String contentType = response.headers().firstValue("content-type").orElse(null); + Map headers = new LinkedHashMap<>(); + response.headers() + .map() + .forEach((name, values) -> headers.put(name, String.join(", ", values))); + log.debug("[external-api] {} -> HTTP {}", safeTarget(target), response.statusCode()); + return new Response(response.statusCode(), contentType, body, headers); + } + + private void applyHeaders(HttpRequest.Builder request, ApiConnectionSettings settings) + throws IOException { + settings.headers().forEach(request::header); + switch (settings.authType()) { + case BEARER -> request.header("Authorization", "Bearer " + settings.token()); + case HEADER -> + request.header( + settings.headerName(), + settings.headerPrefix() == null + ? settings.token() + : settings.headerPrefix() + " " + settings.token()); + case BASIC -> + request.header( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString( + (settings.username() + + ":" + + settings.password()) + .getBytes(StandardCharsets.UTF_8))); + case TOKEN_LOGIN -> { + Map.Entry auth = tokenCache.authHeader(settings); + request.header(auth.getKey(), auth.getValue()); + } + case NONE -> { + /* no credentials */ + } + } + } + + /** Scheme, host and path only: a query string could carry a token an operator put there. */ + private static String safeTarget(URI target) { + return target.getScheme() + "://" + target.getAuthority() + target.getPath(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java new file mode 100644 index 0000000000..055101e381 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiHeaders.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Locale; +import java.util.Set; + +/** + * Validation for operator-supplied HTTP header names and values. + * + *

Header values reach the wire verbatim, so a value carrying CR/LF could splice extra headers - + * or a whole second request - into the stream. Names and values are therefore checked against the + * RFC 7230 grammar rather than trusted. + */ +public final class ExternalApiHeaders { + + /** + * Headers a connection may not set as a static header. Authentication has exactly one path + * ({@code authType} + {@code token}) so credentials cannot be smuggled in as a "static" header + * that bypasses the auth validation; the rest are framing headers owned by the HTTP client, + * where a caller-set value would contradict the body actually sent. + */ + private static final Set RESERVED = + Set.of( + "authorization", + "proxy-authorization", + "host", + "content-length", + "transfer-encoding", + "connection", + "upgrade", + "expect"); + + private ExternalApiHeaders() {} + + /** RFC 7230 {@code token}: the only characters legal in a header name. */ + public static boolean isValidName(String name) { + if (name == null || name.isEmpty()) { + return false; + } + for (int i = 0; i < name.length(); i++) { + if (!isTokenChar(name.charAt(i))) { + return false; + } + } + return true; + } + + /** Visible ASCII, space and horizontal tab. Excludes CR/LF and NUL, which would inject. */ + public static boolean isValidValue(String value) { + if (value == null) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean printable = c >= 0x20 && c <= 0x7E; + if (!printable && c != '\t') { + return false; + } + } + return true; + } + + public static boolean isReserved(String name) { + return name != null && RESERVED.contains(name.toLowerCase(Locale.ROOT)); + } + + private static boolean isTokenChar(char c) { + return (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || "!#$%&'*+-.^_`|~".indexOf(c) >= 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java new file mode 100644 index 0000000000..36ca00816c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ExternalApiPaths.java @@ -0,0 +1,120 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * Resolves a step-supplied relative path under a connection's operator-set base URL. + * + *

This is the control that keeps the external-API step from being an SSRF primitive. The base + * URL comes from an {@code IntegrationConfig} only someone with manage rights can edit; the path + * comes from a pipeline step, which is a far weaker trust boundary. Everything here exists to + * guarantee that a path can address a resource under the base and nothing else. + * + *

{@link URI#resolve} is deliberately not used: resolving the protocol-relative {@code + * //evil.example} against {@code https://api.example.com/v1} yields {@code https://evil.example}, + * silently changing host. Instead the path is screened, appended textually, normalised, and then + * the result is re-checked against the base - so a miss in the screen is still caught by the check. + */ +public final class ExternalApiPaths { + + private ExternalApiPaths() {} + + /** + * @param base the connection's base URL, already validated as http(s) with a host + * @param path a relative path, optionally with a query string; blank means the base itself + * @throws IllegalArgumentException if the path is absolute, escapes the base, or carries + * characters that could split the request line + */ + public static URI resolve(URI base, String path) { + if (path == null || path.isBlank()) { + return base; + } + String candidate = path.trim(); + screen(candidate); + + if (!candidate.startsWith("/")) { + candidate = "/" + candidate; + } + + URI resolved; + try { + resolved = new URI(base + candidate).normalize(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "api step 'path' is not a valid URL path: " + path, e); + } + requireSameOrigin(base, resolved, path); + requireUnderBasePath(base, resolved, path); + return resolved; + } + + /** Reject the shapes that could retarget the request before it is even assembled. */ + private static void screen(String path) { + if (path.contains("://") || path.startsWith("//")) { + throw new IllegalArgumentException( + "api step 'path' must be relative to the connection's base URL, not an" + + " absolute or protocol-relative URL: " + + path); + } + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + // Control characters and spaces can split the request line; a backslash is normalised + // to '/' by some servers and would sidestep the traversal check below. + if (c <= 0x20 || c == 0x7F || c == '\\') { + throw new IllegalArgumentException( + "api step 'path' contains an illegal character: " + path); + } + } + if (path.indexOf('#') >= 0) { + throw new IllegalArgumentException( + "api step 'path' must not contain a fragment: " + path); + } + // Percent-encoded dots would survive the normalise() below and be decoded by the target, so + // a traversal must not be smuggled past us in encoded form. + // + // Only dots are rejected. An encoded slash or backslash is legitimate: Placeholders encodes + // substituted values, so a filename containing '/' arrives here as %2F, where it is data + // inside one segment rather than structure. Rejecting those would refuse ordinary filenames + // while doing nothing for traversal, which needs the dots. + String lower = path.toLowerCase(Locale.ROOT); + if (lower.contains("%2e")) { + throw new IllegalArgumentException( + "api step 'path' must not percent-encode dots: " + path); + } + } + + private static void requireSameOrigin(URI base, URI resolved, String original) { + boolean sameOrigin = + equalsIgnoreCase(base.getScheme(), resolved.getScheme()) + && equalsIgnoreCase(base.getHost(), resolved.getHost()) + && base.getPort() == resolved.getPort() + && resolved.getUserInfo() == null; + if (!sameOrigin) { + throw new IllegalArgumentException( + "api step 'path' would change the target host; it must stay under the" + + " connection's base URL: " + + original); + } + } + + private static void requireUnderBasePath(URI base, URI resolved, String original) { + String basePath = base.getPath() == null ? "" : base.getPath(); + String resolvedPath = resolved.getPath() == null ? "" : resolved.getPath(); + // The base URL has its trailing slash stripped at parse time, so a base path of "/v1" + // must match "/v1" exactly or be followed by a separator - never "/v1betray". + boolean under = + basePath.isEmpty() + || resolvedPath.equals(basePath) + || resolvedPath.startsWith(basePath + "/"); + if (!under) { + throw new IllegalArgumentException( + "api step 'path' escapes the connection's base path: " + original); + } + } + + private static boolean equalsIgnoreCase(String a, String b) { + return a == null ? b == null : a.equalsIgnoreCase(b); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java new file mode 100644 index 0000000000..6118f3b642 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/IntegrationStepValidator.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.integration.api; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.policy.engine.PipelineStepValidator; +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Authorization-checks the {@code connectionId} of any integration step, on the request thread. + * + *

This is what stops an integration step being a confused deputy. A step names a connection by + * id, and the worker thread that runs it has no principal - so {@link ApiConnectionResolver} lets + * the lookup through unchecked there, exactly as the S3 resolver does. Without this validator a + * caller could put any id in a step and have the server dial that tenant's endpoint with that + * tenant's stored credentials. Resolving here, while the caller is still on the thread, forces the + * ownership check to run. + * + *

Registered as a {@link PipelineStepValidator} so both entry points cover it: save-time + * validation of a stored policy, and {@code PolicyController}'s ad-hoc gate. + */ +@Component +@RequiredArgsConstructor +public class IntegrationStepValidator implements PipelineStepValidator { + + static final String CONNECTION_ID_PARAM = "connectionId"; + private static final String INTEGRATION_PREFIX = "/api/v1/integration/"; + + /** + * Which connection type each integration step dereferences. A step under {@link + * #INTEGRATION_PREFIX} that is absent here is rejected rather than waved through, so a new + * endpoint cannot quietly skip this check by forgetting to register. + */ + private static final Map STEP_CONNECTION_TYPES = + Map.of( + "/api/v1/integration/external-api-call", IntegrationType.API, + "/api/v1/integration/purview-apply-label", IntegrationType.PURVIEW, + "/api/v1/integration/purview-read-label", IntegrationType.PURVIEW, + "/api/v1/integration/consigno-submit", IntegrationType.CONSIGNO, + "/api/v1/integration/consigno-fetch-signed", IntegrationType.CONSIGNO); + + private final ApiConnectionResolver connectionResolver; + + @Override + public void validate(PipelineStep step) { + String operation = step.operation(); + if (operation == null || !operation.startsWith(INTEGRATION_PREFIX)) { + return; + } + IntegrationType type = STEP_CONNECTION_TYPES.get(operation); + if (type == null) { + throw new IllegalArgumentException("unknown integration step: " + operation); + } + Long connectionId = + ApiConnectionResolver.connectionId(step.parameters().get(CONNECTION_ID_PARAM)); + if (connectionId == null) { + throw new IllegalArgumentException( + operation + " requires a '" + CONNECTION_ID_PARAM + "' parameter"); + } + // Throws if the connection is missing, the wrong type, disabled, or not usable by the + // caller. The parsed settings are discarded: this call is the check. + connectionResolver.resolveConfig(connectionId, type); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java new file mode 100644 index 0000000000..a848f8599c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/MultipartBody.java @@ -0,0 +1,101 @@ +package stirling.software.proprietary.integration.api; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; + +/** + * Builds a {@code multipart/form-data} body for the JDK HTTP client, which has no multipart + * publisher of its own. + * + *

The body is assembled in memory. Callers bound the document size before getting here; the + * external-API step is for API-shaped payloads, not bulk transfer. + */ +final class MultipartBody { + + private final String boundary; + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + MultipartBody() { + byte[] random = new byte[16]; + new SecureRandom().nextBytes(random); + this.boundary = + "StirlingBoundary" + Base64.getUrlEncoder().withoutPadding().encodeToString(random); + } + + String contentType() { + return "multipart/form-data; boundary=" + boundary; + } + + /** + * @throws IllegalArgumentException if the name could break out of its part header; + * names come from step parameters, so they are checked rather than trusted + */ + MultipartBody addField(String name, String value) throws IOException { + requireSafe(name, "field name"); + writeAscii("--" + boundary + "\r\n"); + writeAscii("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"); + // The value is body, not header: quotes, newlines and backslashes are ordinary data here + // and must survive untouched. Checking it like a header rejected every JSON value - which + // is most of them, the auto-populated context included. + out.write(value.getBytes(StandardCharsets.UTF_8)); + writeAscii("\r\n"); + return this; + } + + MultipartBody addFile(String name, String filename, String contentType, byte[] content) + throws IOException { + requireSafe(name, "file field name"); + requireSafe(filename, "filename"); + writeAscii("--" + boundary + "\r\n"); + writeAscii( + "Content-Disposition: form-data; name=\"" + + name + + "\"; filename=\"" + + filename + + "\"\r\n"); + writeAscii("Content-Type: " + contentType + "\r\n\r\n"); + out.write(content); + writeAscii("\r\n"); + return this; + } + + HttpRequest.BodyPublisher build() throws IOException { + writeAscii("--" + boundary + "--\r\n"); + return HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()); + } + + MultipartBody addFields(Map fields) throws IOException { + for (Map.Entry entry : fields.entrySet()) { + addField(entry.getKey(), entry.getValue()); + } + return this; + } + + /** + * A quote, CR, LF or backslash in a part header - a field name or filename - would let + * it close the quoted string and forge headers of its own. Values are not checked: they are + * body, and the boundary that delimits them is 16 random bytes minted per request, so a value + * cannot end its own part. + */ + private static void requireSafe(String value, String what) { + if (value == null) { + throw new IllegalArgumentException("api step " + what + " must not be null"); + } + if (value.indexOf('"') >= 0 + || value.indexOf('\r') >= 0 + || value.indexOf('\n') >= 0 + || value.indexOf('\\') >= 0) { + throw new IllegalArgumentException( + "api step " + what + " contains an illegal character: " + value); + } + } + + private void writeAscii(String text) throws IOException { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java new file mode 100644 index 0000000000..7a8a3fade3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/Placeholders.java @@ -0,0 +1,153 @@ +package stirling.software.proprietary.integration.api; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; +import tools.jackson.databind.node.StringNode; + +/** + * Substitutes {@code {{dotted.path}}} references against the {@link DocumentContext}. + * + *

This is what lets one step satisfy APIs that disagree about payload shape. Rather than a + * connector per vendor, an operator writes the field names the vendor expects and fills them from + * context - {@code {"sha256": "{{document.sha256}}", "class": "{{sensitivityLabel.name}}"}}. + * + *

Deliberately not a template language: dotted lookup and nothing else. No expressions, no + * control flow, no method calls - a step definition is lower-trust than server config, and the + * whole point of a template engine (evaluating what it is given) is the thing to avoid here. + */ +final class Placeholders { + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*}}"); + + /** How a resolved value is escaped for the position it lands in. */ + enum Escaping { + /** Verbatim: form fields and header values, which are validated separately. */ + NONE, + /** Percent-encoded: a path segment, where a stray slash would change the target. */ + URL_PATH + } + + private Placeholders() {} + + /** + * @param template text that may contain {@code {{...}}} references; null passes through + * @param context the object to resolve against + * @throws IllegalArgumentException if a reference names something the context does not hold, so + * a typo surfaces as an error instead of silently sending an empty value + */ + static String resolve(String template, JsonNode context, Escaping escaping) { + if (template == null || template.isEmpty()) { + return template; + } + Matcher matcher = PLACEHOLDER.matcher(template); + StringBuilder out = new StringBuilder(); + while (matcher.find()) { + String path = matcher.group(1); + JsonNode value = lookup(context, path); + if (value == null || value.isMissingNode()) { + throw new IllegalArgumentException( + "unknown placeholder '{{" + + path + + "}}'; available: document.*, classification.*," + + " sensitivityLabel.*, run.*"); + } + matcher.appendReplacement(out, Matcher.quoteReplacement(render(value, escaping))); + } + matcher.appendTail(out); + return out.toString(); + } + + /** + * Resolve every string in a JSON tree, in place, leaving structure and non-strings alone. + * + *

This is what lets one step post an arbitrary vendor-shaped body - a nested {@code + * documents[0].data} as readily as a flat field - without a connector per vendor. + */ + static JsonNode resolveTree(JsonNode node, JsonNode context) { + if (node instanceof ObjectNode object) { + for (String name : new java.util.ArrayList<>(object.propertyNames())) { + object.set(name, resolveTree(object.get(name), context)); + } + return object; + } + if (node instanceof ArrayNode array) { + for (int i = 0; i < array.size(); i++) { + array.set(i, resolveTree(array.get(i), context)); + } + return array; + } + if (node != null && node.isString()) { + return StringNode.valueOf(resolve(node.asString(), context, Escaping.NONE)); + } + return node; + } + + /** Whether the text references anything at all, so callers can skip resolving. */ + static boolean hasPlaceholder(String text) { + return text != null && PLACEHOLDER.matcher(text).find(); + } + + private static JsonNode lookup(JsonNode context, String path) { + JsonNode node = context; + for (String segment : path.split("\\.")) { + if (node == null || !node.isObject()) { + return null; + } + node = node.get(segment); + } + return node; + } + + /** + * A null in context renders empty rather than the literal "null": absent metadata is a normal + * state, and "null" in a vendor's field would be a value, not an absence. + */ + private static String render(JsonNode value, Escaping escaping) { + String text; + if (value.isNull()) { + text = ""; + } else if (value.isValueNode()) { + text = value.asString(); + } else { + // An object or array (e.g. {{classification}}) renders as its JSON. + text = value.toString(); + } + return escaping == Escaping.URL_PATH ? urlEncodePathSegment(text) : text; + } + + /** + * Encode for a path segment: a filename is the likeliest value to land in a path and may carry + * a slash, which would otherwise read as structure rather than data. + * + *

Dots are left alone even though a traversal is made of them. Encoding them would be worse: + * {@code %2E%2E} survives {@link java.net.URI#normalize()} and gets decoded by the target, so + * the traversal would arrive intact and unexamined. Left raw, {@code ..} normalises here and is + * caught by {@code ExternalApiPaths}' under-the-base check - the one place that can actually + * see it. + */ + private static String urlEncodePathSegment(String text) { + StringBuilder out = new StringBuilder(text.length()); + for (byte b : text.getBytes(java.nio.charset.StandardCharsets.UTF_8)) { + char c = (char) (b & 0xFF); + // RFC 3986 unreserved. + boolean unreserved = + (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '-' + || c == '.' + || c == '_' + || c == '~'; + if (unreserved) { + out.append(c); + } else { + out.append('%').append(String.format("%02X", b & 0xFF)); + } + } + return out.toString(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java new file mode 100644 index 0000000000..f2ceb7988f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultFiles.java @@ -0,0 +1,215 @@ +package stirling.software.proprietary.integration.api; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.ZipExtractionUtils; + +/** + * Works out which bytes, and under which name, a response should contribute to the pipeline. + * + *

Three things go wrong if this is left implicit: + * + *

    + *
  • The name. A step that replaces the document must name it for what came back, not for + * what went out. Keeping the inbound name means a PDF-to-DOCX call-out yields a DOCX called + * {@code .pdf}, and the next step's type check either waves it through or rejects it for the + * wrong reason. The response's own {@code Content-Disposition} or {@code Content-Type} is the + * only honest source. + *
  • Archives. Plenty of APIs answer with a ZIP even when one file was sent - ConsignO + * returns "PDF (single) or ZIP (multiple)". Handing a {@code .zip} to a step expecting a PDF + * is a confusing failure, so a step can select what it wanted out of the archive. + *
  • Nothing useful at all. An empty body or an error page is not a document, and saying + * so beats letting it flow onward as one. + *
+ */ +final class ResultFiles { + + /** Extensions we can name from a content type; anything else keeps the server's filename. */ + private static final Map EXTENSION_BY_TYPE = + Map.ofEntries( + Map.entry("application/pdf", "pdf"), + Map.entry("application/zip", "zip"), + Map.entry("application/json", "json"), + Map.entry("text/plain", "txt"), + Map.entry("text/html", "html"), + Map.entry("image/png", "png"), + Map.entry("image/jpeg", "jpg"), + Map.entry("image/tiff", "tiff"), + Map.entry("application/msword", "doc"), + Map.entry( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "docx"), + Map.entry("application/vnd.ms-excel", "xls"), + Map.entry( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xlsx")); + + private ResultFiles() {} + + /** + * The filename to give the returned bytes. + * + *

Prefers what the server said ({@code Content-Disposition}), then the base name of the + * request with an extension derived from {@code Content-Type}, and only then the original name + * unchanged. + */ + static String nameFor(ExternalApiCaller.Response response, String requestFilename) { + String disposition = response.header("content-disposition"); + String fromServer = filenameFromDisposition(disposition); + if (fromServer != null) { + return fromServer; + } + String extension = extensionFor(response.contentType()); + if (extension == null) { + return requestFilename; + } + return baseName(requestFilename) + "." + extension; + } + + /** + * Pick the file a step asked for out of an archive. + * + * @param select a glob such as {@code *.pdf}, or a 0-based index such as {@code 1} + * @throws IOException if nothing in the archive matches, naming what was there - a silent pick + * of the wrong file would be worse than a failed step + */ + static Resource selectFromArchive( + Resource archive, String select, TempFileManager tempFileManager) throws IOException { + List entries = ZipExtractionUtils.extractZip(archive, tempFileManager); + if (entries.isEmpty()) { + throw new IOException("The API returned an empty archive"); + } + Integer index = asIndex(select); + if (index != null) { + if (index < 0 || index >= entries.size()) { + throw new IOException( + "'responseSelect' asked for entry " + + index + + " but the archive has " + + entries.size() + + ": " + + names(entries)); + } + return entries.get(index); + } + List matches = new ArrayList<>(); + for (Resource entry : entries) { + if (matchesGlob(entry.getFilename(), select)) { + matches.add(entry); + } + } + if (matches.isEmpty()) { + throw new IOException( + "'responseSelect' matched nothing in the archive; it holds " + names(entries)); + } + if (matches.size() > 1) { + // Taking the first would be a coin toss the operator did not ask for. + throw new IOException( + "'responseSelect' matched " + + matches.size() + + " entries (" + + names(matches) + + "); narrow it, or use an index"); + } + return matches.get(0); + } + + /** Whether the chosen name is itself an archive, so its content type is not the entry's. */ + static boolean isArchiveName(String filename) { + return filename != null && filename.toLowerCase(Locale.ROOT).endsWith(".zip"); + } + + static boolean isArchive(Resource resource) throws IOException { + return ZipExtractionUtils.isZip(resource); + } + + static Resource asResource(byte[] content, String filename) { + return new ByteArrayResource(content) { + @Override + public String getFilename() { + return filename; + } + }; + } + + /** Only {@code *} is supported, and only against the entry's own name. */ + private static boolean matchesGlob(String filename, String glob) { + if (filename == null) { + return false; + } + String name = filename.toLowerCase(Locale.ROOT); + String pattern = glob.trim().toLowerCase(Locale.ROOT); + String regex = + java.util.Arrays.stream(pattern.split("\\*", -1)) + .map(java.util.regex.Pattern::quote) + .reduce((a, b) -> a + ".*" + b) + .orElse(""); + return name.matches(regex); + } + + private static Integer asIndex(String select) { + try { + return Integer.valueOf(select.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String names(List entries) { + return entries.stream().map(Resource::getFilename).toList().toString(); + } + + /** {@code attachment; filename="signed.pdf"} or its RFC 5987 {@code filename*} form. */ + private static String filenameFromDisposition(String disposition) { + if (disposition == null) { + return null; + } + for (String part : disposition.split(";")) { + String token = part.trim(); + String value = null; + if (token.regionMatches(true, 0, "filename=", 0, 9)) { + value = token.substring(9).trim(); + } else if (token.regionMatches(true, 0, "filename*=", 0, 10)) { + value = token.substring(10).trim(); + int tick = value.lastIndexOf('\''); + if (tick >= 0) { + value = value.substring(tick + 1); + } + } + if (value == null) { + continue; + } + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + // The name comes from the remote server, so it is treated as data: strip any path it + // tries to bring with it rather than letting it steer where anything is written. + String simple = io.github.pixee.security.Filenames.toSimpleFileName(value); + if (simple != null && !simple.isBlank()) { + return simple; + } + } + return null; + } + + private static String extensionFor(String contentType) { + if (contentType == null) { + return null; + } + String type = contentType.split(";")[0].trim().toLowerCase(Locale.ROOT); + return EXTENSION_BY_TYPE.get(type); + } + + private static String baseName(String filename) { + int dot = filename.lastIndexOf('.'); + return dot <= 0 ? filename : filename.substring(0, dot); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java new file mode 100644 index 0000000000..53d382f3ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/api/ResultUrls.java @@ -0,0 +1,108 @@ +package stirling.software.proprietary.integration.api; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Set; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; + +/** + * Validates a result URL an external API asked us to fetch. + * + *

This is the most dangerous input in the whole feature and deserves saying plainly: unlike a + * step's {@code path}, which an operator wrote, this URL is chosen by the remote service at run + * time. Fetching whatever it names would hand any integration - or anything that has + * compromised, spoofed, or MITM'd one - a server-side GET of its choosing, i.e. the cloud metadata + * service. {@link ExternalApiPaths} cannot help here: the whole point of a result URL is that it + * usually lives on a different host (a CDN or presigned object store), so "must be under the base + * URL" would reject the normal case. + * + *

The rule is therefore an operator-declared allowlist: a result may come from the + * connection's own host, or from a host named in the connection's {@code resultUrlHosts}. The + * decision of which hosts are legitimate stays with whoever configured the connection, and never + * with the response. + */ +final class ResultUrls { + + private ResultUrls() {} + + /** + * @param url exactly as the API returned it + * @return the URL to fetch + * @throws IllegalArgumentException if the response named a host the operator did not authorise + */ + static URI validate( + ApiConnectionSettings settings, + String url, + ApplicationProperties applicationProperties) { + URI uri; + try { + uri = new URI(url.trim()); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "The API returned a result URL that is not a valid URL: " + url, e); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + // file:, gopher:, jar: and friends are how a URL fetch becomes a local file read. + throw new IllegalArgumentException( + "The API returned a result URL that is not http(s): " + url); + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw new IllegalArgumentException( + "The API returned a result URL with no host: " + url); + } + if (uri.getUserInfo() != null) { + // Credentials in a URL are also the classic way to make a host look like another one. + throw new IllegalArgumentException( + "The API returned a result URL carrying credentials, which is not accepted"); + } + + if (!isAllowedHost(settings, host)) { + throw new IllegalArgumentException( + "The API returned a result URL on '" + + host + + "', which this connection does not allow. Add it to the connection's" + + " 'resultUrlHosts' if results are meant to come from there."); + } + // Even an allowlisted name must not resolve somewhere internal: a hostile or compromised + // DNS record for cdn.vendor.example pointing at 169.254.169.254 would otherwise be obeyed. + try { + S3Clients.validateEndpointHost( + uri, + applicationProperties.getPolicies().isAllowPrivateApiEndpoints(), + "API result URL", + "set policies.allowPrivateApiEndpoints=true to opt in (e.g. for an on-prem" + + " integration)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + return uri; + } + + /** + * The connection's own host is implicitly allowed; anything else must be declared. + * + *

Package-private so the matching rule can be tested without a DNS lookup: {@link #validate} + * additionally resolves the host, which fails closed and so cannot run against example hosts. + */ + static boolean isAllowedHost(ApiConnectionSettings settings, String host) { + String candidate = host.toLowerCase(Locale.ROOT); + if (candidate.equalsIgnoreCase(settings.baseUri().getHost())) { + return true; + } + Set allowed = settings.resultUrlHosts(); + for (String entry : allowed) { + String allowedHost = entry.toLowerCase(Locale.ROOT); + // An exact host, or a subdomain of it. Not a bare suffix match: "evilvendor.com" + // must not be admitted by an entry of "vendor.com". + if (candidate.equals(allowedHost) || candidate.endsWith("." + allowedHost)) { + return true; + } + } + return false; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java index 4e79fc5e7a..2c92883aee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -52,6 +52,25 @@ public class IntegrationConfigController { return ResponseEntity.ok(service.toResponse(service.create(request, user), user)); } + /** + * What this caller may set up, so the UI offers the vendor presets and the free-form "custom + * API" option only to those who can actually use them. The answer is computed here rather than + * inferred client-side: hiding a button is presentation, and the service still refuses the call + * regardless of what the client believed. + */ + @GetMapping("/capabilities") + public ResponseEntity capabilities( + @AuthenticationPrincipal User user) { + requireUser(user); + return ResponseEntity.ok( + new IntegrationCapabilitiesResponse(service.canAuthorCustomApi(user))); + } + + /** + * @param customApi whether the caller may author a free-form API integration + */ + public record IntegrationCapabilitiesResponse(boolean customApi) {} + @GetMapping("/{id}") public ResponseEntity get( @PathVariable Long id, @AuthenticationPrincipal User user) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java index 1e5bc0c7d4..375b7f4604 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/model/IntegrationType.java @@ -4,5 +4,10 @@ package stirling.software.proprietary.integration.model; public enum IntegrationType { S3, MCP, - API + /** A generic outbound HTTP endpoint a pipeline step can post a document to. */ + API, + /** Microsoft Purview Information Protection: sensitivity-label taxonomy via Graph. */ + PURVIEW, + /** ConsignO Cloud (Notarius) e-signature and notarization. */ + CONSIGNO } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java new file mode 100644 index 0000000000..28ed8de8f3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabels.java @@ -0,0 +1,323 @@ +package stirling.software.proprietary.integration.purview; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.common.PDMetadata; + +import lombok.extern.slf4j.Slf4j; + +/** + * Reads and writes Microsoft Purview sensitivity labels on a PDF. + * + *

Microsoft documents what a label is - the {@code MSIP_Label__} + * key/value set - but not where it lives inside a PDF; that detail sits inside the MIP + * SDK, which is C++/.NET only and has no Java binding. This class therefore treats the two places a + * PDF can hold such pairs as equally valid: + * + *

    + *
  • the Document Information dictionary, whose custom entries are literally a key/value map; + *
  • the XMP packet, where the same keys appear as properties. + *
+ * + *

Reading is deliberately tolerant - it scans both and takes whichever yields a label - so a + * document labelled by Acrobat, the MIP client, or another vendor is still understood. Writing + * populates both, because a downstream reader may only look at one. + * + *

Scope: this applies the label metadata. It does not encrypt, and cannot: protection + * is enforced by the Azure Rights Management service through the MIP SDK. A label whose policy + * demands encryption will be marked here but not protected, which {@link #apply} refuses to do + * silently. + */ +@Slf4j +public final class PdfSensitivityLabels { + + /** Captures the GUID and the attribute name out of {@code MSIP_Label__}. */ + private static final Pattern LABEL_KEY = + Pattern.compile("^MSIP_Label_([0-9a-fA-F-]{36})_(\\w+)$"); + + /** Finds the same keys inside a raw XMP packet, whatever schema wraps them. */ + private static final Pattern XMP_LABEL_ENTRY = + Pattern.compile( + "<([\\w-]+:)?(MSIP_Label_[0-9a-fA-F-]{36}_\\w+)>([^<]*)", + Pattern.CASE_INSENSITIVE); + + /** + * Adobe's extension schema for carrying arbitrary Document Info entries in XMP. Using it keeps + * the XMP copy standards-shaped instead of inventing a namespace. + */ + private static final String PDFX_NAMESPACE = "http://ns.adobe.com/pdfx/1.3/"; + + private static final int MAX_XMP_BYTES = 8 * 1024 * 1024; + + private PdfSensitivityLabels() {} + + /** + * The label on this document, if any. + * + *

A document carries at most one label per organisation, but may carry labels from several. + * When more than one is present the first found is returned - callers that care about a + * specific tenant should compare {@link SensitivityLabel#siteId()}. + */ + public static Optional read(PDDocument document) { + List all = readAll(document); + return all.isEmpty() ? Optional.empty() : Optional.of(all.get(0)); + } + + /** Every label on the document, across both metadata surfaces, de-duplicated by GUID. */ + public static List readAll(PDDocument document) { + Map> byLabelId = new LinkedHashMap<>(); + collect(infoPairs(document), byLabelId); + collect(xmpPairs(document), byLabelId); + + List labels = new ArrayList<>(); + byLabelId.forEach( + (labelId, attributes) -> { + SensitivityLabel label = SensitivityLabel.fromAttributes(labelId, attributes); + if (label != null) { + labels.add(label); + } + }); + return labels; + } + + /** + * Apply a label, replacing any the same tenant already set. + * + * @throws IllegalArgumentException if the label claims encryption, which this cannot honour + */ + public static void apply(PDDocument document, SensitivityLabel label) throws IOException { + if (label.isProtected()) { + // Writing ContentBits=ENCRYPT onto an unencrypted file would tell every downstream + // reader the content is protected when it is plaintext. Refuse rather than lie. + throw new IllegalArgumentException( + "This label requires encryption, which needs the Microsoft Purview client or" + + " MIP SDK; Stirling can apply the label metadata but cannot protect" + + " the content."); + } + // "An object can only have one label from the same organization." Replace this tenant's + // labels on both surfaces, but leave other tenants' labels untouched on both. + Set replaced = labelIdsOfTenant(document, label.siteId()); + replaced.add(label.labelId()); + Map pairs = label.toMetadata(); + removeInfoLabels(document, replaced::contains); + writeInfo(document, pairs); + writeXmp(document, pairs, replaced::contains); + } + + /** Strip every label, e.g. before re-labelling or when downgrading a document. */ + public static void clear(PDDocument document) throws IOException { + removeInfoLabels(document, labelId -> true); + writeXmp(document, Map.of(), labelId -> true); + } + + /** The GUIDs of labels this tenant already set, so both surfaces can drop exactly those. */ + private static Set labelIdsOfTenant(PDDocument document, String siteId) { + Set ids = new LinkedHashSet<>(); + for (SensitivityLabel existing : readAll(document)) { + if (siteId.equalsIgnoreCase(existing.siteId())) { + ids.add(existing.labelId()); + } + } + return ids; + } + + /** Drop info-dictionary label entries whose GUID the predicate selects. */ + private static void removeInfoLabels(PDDocument document, Predicate removeLabelId) { + PDDocumentInformation info = document.getDocumentInformation(); + for (String key : new ArrayList<>(info.getMetadataKeys())) { + Matcher matcher = LABEL_KEY.matcher(key); + if (matcher.matches() && removeLabelId.test(matcher.group(1))) { + info.setCustomMetadataValue(key, null); + } + } + } + + private static void writeInfo(PDDocument document, Map pairs) { + PDDocumentInformation info = document.getDocumentInformation(); + pairs.forEach(info::setCustomMetadataValue); + } + + /** + * Rewrite the XMP packet's label properties, leaving the rest of the packet untouched. + * + *

The packet is edited textually rather than re-serialised through xmpbox: a document's XMP + * may carry schemas xmpbox does not model, and a round-trip through it would silently drop + * them. + */ + private static void writeXmp( + PDDocument document, Map pairs, Predicate removeLabelId) + throws IOException { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + String existing = readXmpString(catalog); + if (existing == null) { + if (pairs.isEmpty()) { + return; + } + existing = emptyPacket(); + } + String stripped = stripLabels(existing, removeLabelId); + String updated = insertLabelProperties(stripped, pairs); + if (updated == null) { + log.debug("XMP packet has no rdf:Description to hold the label; info dictionary only"); + return; + } + PDMetadata metadata = new PDMetadata(document); + metadata.importXMPMetadata(updated.getBytes(StandardCharsets.UTF_8)); + catalog.setMetadata(metadata); + } + + /** Remove only the XMP label entries whose GUID the predicate selects, keeping the rest. */ + private static String stripLabels(String packet, Predicate removeLabelId) { + Matcher matcher = XMP_LABEL_ENTRY.matcher(packet); + StringBuilder out = new StringBuilder(); + while (matcher.find()) { + Matcher key = LABEL_KEY.matcher(matcher.group(2)); + boolean remove = key.matches() && removeLabelId.test(key.group(1)); + matcher.appendReplacement(out, Matcher.quoteReplacement(remove ? "" : matcher.group())); + } + matcher.appendTail(out); + return out.toString(); + } + + /** Splice the properties into the first {@code rdf:Description}; null when there is none. */ + private static String insertLabelProperties(String packet, Map pairs) { + if (pairs.isEmpty()) { + return packet; + } + Matcher description = Pattern.compile("]*>").matcher(packet); + if (!description.find()) { + return null; + } + StringBuilder properties = new StringBuilder(); + pairs.forEach( + (key, value) -> + properties + .append("\n ') + .append(escapeXml(value)) + .append("')); + String opening = description.group(); + String withNamespace = + opening.contains("xmlns:pdfx=") + ? opening + : opening.substring(0, opening.length() - 1) + + " xmlns:pdfx=\"" + + PDFX_NAMESPACE + + "\">"; + return packet.substring(0, description.start()) + + withNamespace + + properties + + packet.substring(description.end()); + } + + private static Map infoPairs(PDDocument document) { + Map pairs = new LinkedHashMap<>(); + PDDocumentInformation info = document.getDocumentInformation(); + for (String key : info.getMetadataKeys()) { + String value = info.getCustomMetadataValue(key); + if (value != null) { + pairs.put(key, value); + } + } + return pairs; + } + + private static Map xmpPairs(PDDocument document) { + Map pairs = new LinkedHashMap<>(); + String packet; + try { + packet = readXmpString(document.getDocumentCatalog()); + } catch (IOException e) { + log.debug( + "Unreadable XMP packet; falling back to the info dictionary: {}", + e.getMessage()); + return pairs; + } + if (packet == null) { + return pairs; + } + Matcher matcher = XMP_LABEL_ENTRY.matcher(packet); + while (matcher.find()) { + pairs.put(matcher.group(2), unescapeXml(matcher.group(3).trim())); + } + return pairs; + } + + /** Group raw pairs by label GUID, keeping the attribute name as the key. */ + private static void collect(Map pairs, Map> into) { + pairs.forEach( + (key, value) -> { + Matcher matcher = LABEL_KEY.matcher(key); + if (!matcher.matches()) { + return; + } + into.computeIfAbsent(matcher.group(1), id -> new LinkedHashMap<>()) + // Info-dictionary pairs are collected first and win: a stale XMP copy + // must not override the value the labelling client wrote. + .putIfAbsent(matcher.group(2), value); + }); + } + + private static String readXmpString(PDDocumentCatalog catalog) throws IOException { + PDMetadata metadata = catalog.getMetadata(); + if (metadata == null) { + return null; + } + try (InputStream is = metadata.exportXMPMetadata()) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + int total = 0; + while ((read = is.read(chunk)) != -1) { + total += read; + if (total > MAX_XMP_BYTES) { + // A hostile document could otherwise hand us an unbounded packet to hold. + throw new IOException("XMP packet exceeds " + MAX_XMP_BYTES + " bytes"); + } + buffer.write(chunk, 0, read); + } + return buffer.toString(StandardCharsets.UTF_8); + } + } + + private static String emptyPacket() { + return "" + + "" + + "" + + "" + + ""; + } + + private static String escapeXml(String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static String unescapeXml(String value) { + return value.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java new file mode 100644 index 0000000000..cc40872293 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewConnectionSettings.java @@ -0,0 +1,94 @@ +package stirling.software.proprietary.integration.purview; + +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * A Microsoft Purview tenant connection. + * + *

Only {@code tenantId} is required, because labelling a document needs nothing else: a label is + * a set of key/value pairs and the tenant id is the {@code SiteId} among them. No call to Microsoft + * is involved, so the step works with no network and no app registration. + * + *

The app-registration fields are optional and buy exactly one thing: reading the tenant's label + * taxonomy from Graph, so the UI can offer a list of labels instead of asking someone to paste a + * GUID. They are not needed to apply or read a label. Graph cannot apply labels for an application + * anyway - "application permissions are not supported when updating assignedLabels" - which is why + * labelling here goes through the published metadata contract instead. + */ +public record PurviewConnectionSettings( + String tenantId, + String clientId, + String clientSecret, + String graphBaseUrl, + String loginBaseUrl) { + + static final String TENANT_ID_OPTION = "tenantId"; + static final String CLIENT_ID_OPTION = "clientId"; + // Contains a SecretMasker hint, so it masks on read and merges on update. + static final String CLIENT_SECRET_OPTION = "clientSecret"; + static final String GRAPH_BASE_URL_OPTION = "graphBaseUrl"; + static final String LOGIN_BASE_URL_OPTION = "loginBaseUrl"; + + public static final String DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com"; + public static final String DEFAULT_LOGIN_BASE_URL = "https://login.microsoftonline.com"; + + /** Entra tenant ids are GUIDs; the value ends up in document metadata, so it is checked. */ + private static final Pattern GUID = + Pattern.compile("^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$"); + + public static PurviewConnectionSettings from(Map options) { + String tenantId = trimmed(options.get(TENANT_ID_OPTION)); + if (tenantId == null) { + throw new IllegalArgumentException("purview config requires a 'tenantId'"); + } + if (!GUID.matcher(tenantId).matches()) { + throw new IllegalArgumentException( + "purview config 'tenantId' must be a GUID, e.g." + + " cb46c030-1825-4e81-a295-151c039dbf02"); + } + String clientId = trimmed(options.get(CLIENT_ID_OPTION)); + String clientSecret = trimmed(options.get(CLIENT_SECRET_OPTION)); + // Half an app registration would fail only when someone opened the label picker, which is + // a confusing place to discover it. + if ((clientId == null) != (clientSecret == null)) { + throw new IllegalArgumentException( + "purview config needs both 'clientId' and 'clientSecret' to read the label" + + " list, or neither"); + } + return new PurviewConnectionSettings( + tenantId.toLowerCase(Locale.ROOT), + clientId, + clientSecret, + orDefault(trimmed(options.get(GRAPH_BASE_URL_OPTION)), DEFAULT_GRAPH_BASE_URL), + orDefault(trimmed(options.get(LOGIN_BASE_URL_OPTION)), DEFAULT_LOGIN_BASE_URL)); + } + + /** Whether this connection can read the tenant's label taxonomy from Graph. */ + public boolean canListLabels() { + return clientId != null && clientSecret != null; + } + + private static String orDefault(String value, String fallback) { + return value == null ? fallback : value; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the client secret, so an accidental log line cannot leak it. */ + @Override + public String toString() { + return "PurviewConnectionSettings[tenantId=" + + tenantId + + ", canListLabels=" + + canListLabels() + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java new file mode 100644 index 0000000000..485345e5cd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewIntegrationValidator.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.integration.purview; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** The Purview connection schema, enforced when the config is saved. */ +@Component +public class PurviewIntegrationValidator implements IntegrationConfigValidator { + + @Override + public IntegrationType type() { + return IntegrationType.PURVIEW; + } + + @Override + public void validate(Map config) { + PurviewConnectionSettings.from(config); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java new file mode 100644 index 0000000000..65555077c3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/PurviewLabelController.java @@ -0,0 +1,184 @@ +package stirling.software.proprietary.integration.purview; + +import java.io.IOException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.integration.api.ApiConnectionResolver; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Purview sensitivity labelling as policy steps. + * + *

Both steps are local: a label is metadata, so applying and reading one involves no call to + * Microsoft. The connection supplies the tenant id that becomes the label's {@code SiteId}. + * + *

{@code purview-read-label} exists to make labels actionable: it reports what a + * document already carries, so a policy can branch on it - the case Purview itself does not cover, + * since it labels documents but does not process them. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/integration") +@RequiredArgsConstructor +@Tag(name = "Integrations", description = "Third-party integration steps.") +public class PurviewLabelController { + + private final ApiConnectionResolver connectionResolver; + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final TempFileManager tempFileManager; + private final ObjectMapper objectMapper; + + @PostMapping(value = "/purview-apply-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a Microsoft Purview sensitivity label", + description = + "Writes the Purview label metadata (MSIP_Label__*) onto the PDF, so" + + " Purview-aware tools recognise the label. Applies the label only;" + + " it cannot encrypt, which requires the Microsoft client." + + " Input:PDF Output:PDF Type:SISO") + public ResponseEntity applyLabel( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId, + @RequestParam("labelId") String labelId, + @RequestParam(value = "labelName", required = false) String labelName, + @RequestParam(value = "method", defaultValue = "STANDARD") String method, + @RequestParam(value = "contentBits", required = false) Integer contentBits) + throws IOException { + + PurviewConnectionSettings settings = settings(connectionId); + AssignmentMethod assignment = parseMethod(method); + + try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { + String fileName = safeFileName(fileInput.getOriginalFilename()); + SensitivityLabel label = + new SensitivityLabel( + labelId.trim(), + labelName, + settings.tenantId(), + assignment, + Instant.now(), + contentBits); + PdfSensitivityLabels.apply(document, label); + log.debug("[purview-apply-label] labelled {} as {}", fileName, labelId); + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + } + + @PostMapping(value = "/purview-read-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Read the Microsoft Purview sensitivity label on a PDF", + description = + "Reports the Purview labels a PDF already carries so a policy can act on" + + " them. The document passes through unchanged." + + " Input:PDF Output:PDF Type:SISO") + public ResponseEntity readLabel( + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam("connectionId") String connectionId) + throws IOException { + + PurviewConnectionSettings settings = settings(connectionId); + + List labels; + try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { + labels = PdfSensitivityLabels.readAll(document); + } + // The document is returned byte-for-byte rather than re-saved: a read must not perturb the + // file it inspected, and a PDFBox round-trip would rewrite its structure. + byte[] bytes = fileInput.getBytes(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDispositionFormData( + "attachment", safeFileName(fileInput.getOriginalFilename())); + headers.setContentLength(bytes.length); + headers.set(AiToolResponseHeaders.TOOL_REPORT, buildReport(labels, settings)); + return ResponseEntity.ok().headers(headers).body(new ByteArrayResource(bytes)); + } + + /** + * The labels found, and which of them is this tenant's - a document can carry labels from + * several organisations, and only the matching one reflects this tenant's policy. + */ + private String buildReport(List labels, PurviewConnectionSettings settings) { + Optional own = + labels.stream() + .filter(label -> settings.tenantId().equalsIgnoreCase(label.siteId())) + .findFirst(); + ObjectNode report = objectMapper.createObjectNode(); + report.put("labelled", own.isPresent()); + own.ifPresent( + label -> { + report.put("labelId", label.labelId()); + report.put("labelName", label.name()); + report.put("method", label.method() == null ? null : label.method().name()); + report.put( + "setDate", label.setDate() == null ? null : label.setDate().toString()); + report.put("contentBits", label.contentBits()); + report.put("protected", label.isProtected()); + }); + ArrayNode others = report.putArray("otherTenantLabels"); + labels.stream() + .filter(label -> !settings.tenantId().equalsIgnoreCase(label.siteId())) + .forEach( + label -> { + ObjectNode node = others.addObject(); + node.put("labelId", label.labelId()); + node.put("siteId", label.siteId()); + }); + return objectMapper.writeValueAsString(report); + } + + private PurviewConnectionSettings settings(String connectionId) { + Long id = ApiConnectionResolver.connectionId(connectionId); + if (id == null) { + throw new IllegalArgumentException("'connectionId' is required"); + } + return PurviewConnectionSettings.from( + connectionResolver.resolveConfig(id, IntegrationType.PURVIEW)); + } + + private static AssignmentMethod parseMethod(String method) { + AssignmentMethod parsed = AssignmentMethod.parse(method); + if (parsed == null) { + throw new IllegalArgumentException( + "'method' must be STANDARD (applied automatically) or PRIVILEGED (chosen by a" + + " person); got " + + method); + } + return parsed; + } + + private static String safeFileName(String originalFilename) { + String name = Filenames.toSimpleFileName(originalFilename); + return (name == null || name.isBlank()) ? "labelled.pdf" : name; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java new file mode 100644 index 0000000000..5f6e97452e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/purview/SensitivityLabel.java @@ -0,0 +1,186 @@ +package stirling.software.proprietary.integration.purview; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * One Microsoft Purview Information Protection label as it is written to a document. + * + *

Microsoft persists a label as a flat set of key/value pairs named {@code + * MSIP_Label__}, and documents that contract publicly so third-party software can + * read a label and act on it. That published contract - not the MIP SDK, which has no Java binding + * - is what this type implements. See Label + * metadata in the MIP SDK. + * + *

Only {@code Enabled} and {@code SiteId} are mandatory in that contract; the rest are optional + * and may be absent on a label written by an older client, so readers here tolerate their absence. + */ +public record SensitivityLabel( + String labelId, + String name, + String siteId, + AssignmentMethod method, + Instant setDate, + Integer contentBits) { + + /** How the label came to be applied. */ + public enum AssignmentMethod { + /** Applied by default or automatically - e.g. by a policy like this one. */ + STANDARD, + /** Chosen deliberately by a person. */ + PRIVILEGED; + + String wireValue() { + return name().charAt(0) + name().substring(1).toLowerCase(Locale.ROOT); + } + + static AssignmentMethod parse(String value) { + if (value == null) { + return null; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } + } + + public static final String KEY_PREFIX = "MSIP_Label_"; + + /** Content marks the labelling application applied; a bitmask, per the MIP contract. */ + public static final int CONTENT_BITS_HEADER = 0x1; + + public static final int CONTENT_BITS_FOOTER = 0x2; + public static final int CONTENT_BITS_WATERMARK = 0x4; + public static final int CONTENT_BITS_ENCRYPT = 0x8; + + /** + * Extended ISO 8601, matching the {@code 2018-11-08T21:13:16-0800} form Microsoft documents. + */ + private static final DateTimeFormatter SET_DATE = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT) + .withZone(ZoneOffset.UTC); + + /** + * Microsoft caps each key and value at 255 characters "to maintain compatibility across common + * applications". + */ + static final int MAX_VALUE_LENGTH = 255; + + /** The GUID shape a labelId must take, matching what the read path accepts from a document. */ + private static final Pattern LABEL_ID = Pattern.compile("^[0-9a-fA-F-]{36}$"); + + public SensitivityLabel { + if (labelId == null || labelId.isBlank()) { + throw new IllegalArgumentException("a sensitivity label needs a labelId"); + } + if (!LABEL_ID.matcher(labelId).matches()) { + // labelId is spliced verbatim into XMP/info key names; a non-GUID would let a stray + // character (a space, or <, >, &) corrupt or inject the metadata it is written into. + throw new IllegalArgumentException("a sensitivity label needs a GUID labelId"); + } + if (siteId == null || siteId.isBlank()) { + throw new IllegalArgumentException("a sensitivity label needs a siteId (tenant id)"); + } + } + + /** The {@code MSIP_Label__} prefix this label's keys share. */ + public String keyPrefix() { + return KEY_PREFIX + labelId + "_"; + } + + /** + * This label as the key/value pairs to persist. Optional attributes are omitted when unset + * rather than written empty, so a reader cannot mistake "not recorded" for "recorded as blank". + */ + public Map toMetadata() { + Map out = new LinkedHashMap<>(); + String prefix = keyPrefix(); + out.put(prefix + "Enabled", "true"); + out.put(prefix + "SiteId", siteId); + if (method != null) { + out.put(prefix + "Method", method.wireValue()); + } + if (setDate != null) { + out.put(prefix + "SetDate", SET_DATE.format(setDate)); + } + if (name != null && !name.isBlank()) { + out.put(prefix + "Name", truncate(name)); + } + if (contentBits != null) { + out.put(prefix + "ContentBits", String.valueOf(contentBits)); + } + return out; + } + + /** + * Rebuild a label from the pairs found on a document. + * + * @param labelId the GUID between the prefix and the attribute name + * @param attributes attribute name (e.g. {@code Name}) to value, for that GUID only + * @return null when the pairs do not describe an enabled label + */ + static SensitivityLabel fromAttributes(String labelId, Map attributes) { + // "DLP products typically validate the existence of this key to identify the + // classification label" - an absent or false Enabled means there is no label here. + if (!"true".equalsIgnoreCase(attributes.get("Enabled"))) { + return null; + } + String siteId = attributes.get("SiteId"); + if (siteId == null || siteId.isBlank()) { + // SiteId is mandatory in the contract, but a label written by something non-compliant + // is still a label; keep it readable rather than throwing on someone else's file. + siteId = "unknown"; + } + return new SensitivityLabel( + labelId, + attributes.get("Name"), + siteId, + AssignmentMethod.parse(attributes.get("Method")), + parseDate(attributes.get("SetDate")), + parseInt(attributes.get("ContentBits"))); + } + + private static Instant parseDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return SET_DATE.parse(value.trim(), Instant::from); + } catch (RuntimeException e) { + try { + // Tolerate the plain ISO form some writers use instead. + return Instant.parse(value.trim()); + } catch (RuntimeException ignored) { + return null; + } + } + } + + private static Integer parseInt(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private static String truncate(String value) { + return value.length() <= MAX_VALUE_LENGTH ? value : value.substring(0, MAX_VALUE_LENGTH); + } + + /** Whether the labelling application encrypted the content. */ + public boolean isProtected() { + return contentBits != null && (contentBits & CONTENT_BITS_ENCRYPT) != 0; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index a7ef7d4512..a6650569a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -13,6 +13,7 @@ import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.access.model.DefaultAccessPolicy; import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.access.model.ResourceType; @@ -43,6 +44,7 @@ public class IntegrationConfigService { private final OwnershipService ownership; private final SecretMasker secretMasker; private final ResourceGrantRepository grantRepository; + private final ApplicationProperties applicationProperties; // Bean-discovered extension points: features that understand a type contribute its config // schema and report what still references a config, without this module depending on them. private final List validators; @@ -62,6 +64,7 @@ public class IntegrationConfigService { && !ownership.isAdmin(currentUser)) { throw forbidden("S3 connections can only be created by administrators or team owners"); } + requireCustomApiAllowed(cfg.getIntegrationType(), currentUser); cfg.setName(require(request.name(), "name")); cfg.setEnabled(request.enabled() == null || request.enabled()); cfg.setLocked(request.locked() != null && request.locked()); @@ -113,6 +116,9 @@ public class IntegrationConfigService { cfg.setDefaultAccess(request.defaultAccess()); } if (request.config() != null) { + // Editing the config of a custom integration is the same authoring power as creating + // one - it is where the base URL and body live - so it is gated identically. + requireCustomApiAllowed(cfg.getIntegrationType(), currentUser); Map merged = secretMasker.merge(readJson(cfg.getConfig()), request.config()); validateConfig(cfg.getIntegrationType(), merged); @@ -121,6 +127,32 @@ public class IntegrationConfigService { return repository.save(cfg); } + /** + * A custom API integration names its own host, path and body, so it can point the server + * anywhere. That is authoring power rather than self-serve configuration: admins only, and the + * operator can withdraw it entirely. The vendor presets are not gated here - they carry a fixed + * shape, so the worst a user can do is misconfigure their own connection. + */ + private void requireCustomApiAllowed(IntegrationType type, User currentUser) { + if (type != IntegrationType.API) { + return; + } + if (!applicationProperties.getPolicies().isAllowCustomApiIntegrations()) { + throw forbidden( + "Custom API integrations are disabled on this server" + + " (policies.allowCustomApiIntegrations)"); + } + if (!ownership.isAdmin(currentUser)) { + throw forbidden("Custom API integrations can only be created by administrators"); + } + } + + /** Whether this caller may author custom API integrations, for the UI to offer or hide it. */ + public boolean canAuthorCustomApi(User currentUser) { + return applicationProperties.getPolicies().isAllowCustomApiIntegrations() + && ownership.isAdmin(currentUser); + } + @Transactional public void delete(Long id, User currentUser) { IntegrationConfig cfg = load(id); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index e0df6ba2b1..06172b8cbc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -123,7 +123,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); - validateAdHocOutput(definition); + validateAdHocRun(definition); PolicyInputs inputs = toInputs(files); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); @@ -144,7 +144,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); - validateAdHocOutput(definition); + validateAdHocRun(definition); PolicyInputs inputs = toInputs(files); SseEmitter emitter = @@ -560,18 +560,22 @@ public class PolicyController { } /** - * Authorization-check an ad-hoc run's output while the caller's principal is present (this - * request thread). The worker thread that later delivers carries no security context, so an S3 - * output's connection-access check would be skipped there; without this gate a caller could - * reference another tenant's connection by id and write to it (confused deputy). Stored - * policies are covered by save-time {@link PolicyValidator#validate} instead. + * Authorization-check an ad-hoc run's steps and output while the caller's principal is present + * (this request thread). The worker thread that later runs and delivers carries no security + * context, so a connection-access check would be skipped there; without this gate a caller + * could reference another tenant's connection by id and write to it, or make the server call it + * with its stored credentials (confused deputy). Stored policies are covered by save-time + * {@link PolicyValidator#validate} instead. */ - private void validateAdHocOutput(PipelineDefinition definition) { - if (definition.output() == null) { - return; - } + private void validateAdHocRun(PipelineDefinition definition) { try { - policyValidator.validateOutput(definition.output()); + // Steps get the same treatment as the output, and for the same reason: an integration + // step dereferences its connection by id on a principal-less worker thread, so this + // request thread is the only place that reference can be checked against the caller. + policyValidator.validateSteps(definition.steps()); + if (definition.output() != null) { + policyValidator.validateOutput(definition.output()); + } } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java new file mode 100644 index 0000000000..fc440609a0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PipelineStepValidator.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.policy.engine; + +import stirling.software.proprietary.policy.model.PipelineStep; + +/** + * Validates one step's parameters before a run is admitted. Implementations are beans discovered by + * {@link PolicyValidator}, so the feature that understands a step's parameters owns their rules + * without the engine depending on it. + * + *

Steps run on a worker thread with no {@code SecurityContext}, so anything a step dereferences + * by id - an integration connection, say - cannot be authorization-checked at run time. A validator + * that resolves such a reference must therefore be called while the caller's principal is still + * present, which is what {@link PolicyValidator#validateSteps} guarantees. + */ +public interface PipelineStepValidator { + + /** + * @throws IllegalArgumentException if the step is misconfigured or references something the + * current caller may not use + */ + void validate(PipelineStep step); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index cac05765a0..c2d1357889 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.TriggerConfig; import stirling.software.proprietary.policy.output.PolicyOutputSink; @@ -17,10 +18,10 @@ import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; /** - * Validates a policy at save time by delegating each facet (trigger, sources, output) to the bean - * that handles its type, so a misconfiguration fails fast rather than at run time. A null trigger - * is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} must - * resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. + * Validates a policy at save time by delegating each facet (trigger, sources, steps, output) to the + * bean that handles its type, so a misconfiguration fails fast rather than at run time. A null + * trigger is a manual-only policy and skips trigger validation. Each referenced {@code sourceId} + * must resolve to a persisted {@link Source} whose config its {@link InputSource} bean accepts. */ @Service @RequiredArgsConstructor @@ -29,6 +30,7 @@ public class PolicyValidator { private final List triggers; private final List inputSources; private final List outputSinks; + private final List stepValidators; private final SourceStore sourceStore; /** @@ -50,9 +52,27 @@ public class PolicyValidator { InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } + validateSteps(policy.steps()); validateOutput(policy.output()); } + /** + * Validate each step against every registered {@link PipelineStepValidator}. Must be called on + * a request thread (caller's principal present) for the same reason as {@link + * #validateOutput(OutputSpec)}: a step that dereferences an integration connection by id is + * access-checked here or nowhere, since the worker thread that later runs it has no principal. + * + * @throws IllegalArgumentException if any step is invalid or references an inaccessible + * resource + */ + public void validateSteps(List steps) { + for (PipelineStep step : steps) { + for (PipelineStepValidator validator : stepValidators) { + validator.validate(step); + } + } + } + /** * Validate an output spec against its sink. Must be called on a request thread (caller's * principal present) so an S3 output's connection is authorization-checked against the caller - diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java index 4f76b06b5c..dbd8c02131 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -77,6 +77,48 @@ class SecretMaskerTest { assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey"); } + @Test + void maskRedactsEveryHeaderValueRegardlessOfName() { + Map headers = new LinkedHashMap<>(); + headers.put("X-API-Key", "real-secret"); // name carries no secret hint + headers.put("Ocp-Apim-Subscription-Key", "abc123"); + headers.put("Content-Type", "application/json"); + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "https://api.example"); + config.put("headers", headers); + + Map masked = masker.mask(config); + + assertThat(masked.get("baseUrl")).isEqualTo("https://api.example"); + @SuppressWarnings("unchecked") + Map maskedHeaders = (Map) masked.get("headers"); + assertThat(maskedHeaders.get("X-API-Key")).isEqualTo(SecretMasker.MASK); + assertThat(maskedHeaders.get("Ocp-Apim-Subscription-Key")).isEqualTo(SecretMasker.MASK); + assertThat(maskedHeaders.get("Content-Type")).isEqualTo(SecretMasker.MASK); + } + + @Test + void mergeRestoresRedactedHeaderValuesFromStored() { + Map storedHeaders = new LinkedHashMap<>(); + storedHeaders.put("X-API-Key", "REAL"); + storedHeaders.put("Content-Type", "application/json"); + Map stored = new LinkedHashMap<>(); + stored.put("headers", storedHeaders); + + Map incomingHeaders = new LinkedHashMap<>(); + incomingHeaders.put("X-API-Key", SecretMasker.MASK); // untouched secret comes back masked + incomingHeaders.put("Content-Type", "text/plain"); // genuinely edited + Map incoming = new LinkedHashMap<>(); + incoming.put("headers", incomingHeaders); + + Map merged = masker.merge(stored, incoming); + + @SuppressWarnings("unchecked") + Map mergedHeaders = (Map) merged.get("headers"); + assertThat(mergedHeaders.get("X-API-Key")).isEqualTo("REAL"); // restored, not "********" + assertThat(mergedHeaders.get("Content-Type")).isEqualTo("text/plain"); // updated + } + @Test void deeplyNestedInputIsBoundedNotOverflowing() { // Build a structure far deeper than the recursion cap. diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java new file mode 100644 index 0000000000..e767aae34d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; + +/** + * The private-endpoint opt-in is coarse by design (it lets an on-prem integration reach RFC1918), + * but it must never open the cloud metadata service - the one internal address whose only use is + * stealing the instance's credentials. + */ +class ApiIntegrationValidatorTest { + + private final ApiIntegrationValidator validator = + new ApiIntegrationValidator(properties(false)); + + private static ApplicationProperties properties(boolean allowPrivate) { + ApplicationProperties p = new ApplicationProperties(); + p.getPolicies().setAllowPrivateApiEndpoints(allowPrivate); + return p; + } + + private static Map config(String baseUrl) { + Map c = new LinkedHashMap<>(); + c.put("baseUrl", baseUrl); + return c; + } + + @Test + void acceptsAnOrdinaryPublicHost() { + // A public IP literal, so the check needs no network DNS (an unresolvable name would fail + // closed at the resolve step, which is correct but not what this test is about). + assertThatCode(() -> validator.validate(config("https://1.1.1.1/v1"))) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAPrivateHostByDefault() { + assertThatThrownBy(() -> validator.validate(config("http://10.0.0.5/x"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsTheCloudMetadataAddressEvenWithThePrivateOptInOn() { + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + // The on-prem opt-in allows RFC1918, but the metadata endpoint stays blocked. + assertThatThrownBy(() -> opted.validate(config("http://169.254.169.254/latest/meta-data/"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("metadata service"); + } + + @Test + void aPrivateOnPremHostIsAllowedWhenOptedIn() { + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + assertThatCode(() -> opted.validate(config("http://10.10.0.20:8080/api"))) + .doesNotThrowAnyException(); + } + + @Test + void theMetadataBlockRunsBeforeTheOptInSoItCannotBeBypassed() { + // Also covers the Oracle/IBM variants that share the 169.254.169.x range. + ApiIntegrationValidator opted = new ApiIntegrationValidator(properties(true)); + + assertThatThrownBy(() -> opted.validate(config("http://169.254.169.253/"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("metadata service"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java new file mode 100644 index 0000000000..4576584b58 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/DocumentContextTest.java @@ -0,0 +1,177 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Instant; +import java.util.Base64; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.service.PdfMetadataService; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * The context is what an external API gets told about the document, so it is asserted concretely. + */ +class DocumentContextTest { + + private static final String TENANT = "cb46c030-1825-4e81-a295-151c039dbf02"; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static byte[] pdfBytes(java.util.function.Consumer customise) + throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + document.addPage(new PDPage()); + customise.accept(document); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private ObjectNode contextOf(byte[] content, String filename, String policyName, String runId) { + MockMultipartFile file = + new MockMultipartFile("fileInput", filename, "application/pdf", content); + return DocumentContext.build(file, content, policyName, runId, objectMapper); + } + + @Test + void describesThePdfAndTheRun() throws IOException { + byte[] content = + pdfBytes( + document -> { + document.getDocumentInformation().setTitle("Q3 Invoice"); + document.getDocumentInformation().setAuthor("Anthony"); + }); + + ObjectNode context = contextOf(content, "invoice.pdf", "Outbound review", "run-42"); + + assertThat(context.at("/document/filename").asString()).isEqualTo("invoice.pdf"); + assertThat(context.at("/document/extension").asString()).isEqualTo("pdf"); + assertThat(context.at("/document/contentType").asString()).isEqualTo("application/pdf"); + assertThat(context.at("/document/sizeBytes").asInt()).isEqualTo(content.length); + assertThat(context.at("/document/pageCount").asInt()).isEqualTo(2); + assertThat(context.at("/document/encrypted").asBoolean()).isFalse(); + assertThat(context.at("/document/title").asString()).isEqualTo("Q3 Invoice"); + assertThat(context.at("/document/author").asString()).isEqualTo("Anthony"); + assertThat(context.at("/run/policyName").asString()).isEqualTo("Outbound review"); + assertThat(context.at("/run/runId").asString()).isEqualTo("run-42"); + assertThat(Instant.parse(context.at("/run/timestamp").asString())).isNotNull(); + } + + @Test + void hashesTheContentTheApiWillReceive() throws IOException { + byte[] content = pdfBytes(document -> {}); + + String sha = contextOf(content, "a.pdf", null, null).at("/document/sha256").asString(); + + assertThat(sha).hasSize(64).matches("[0-9a-f]{64}"); + // Same bytes, same hash: external systems key on this for dedupe and chain-of-custody. + assertThat(contextOf(content, "renamed.pdf", null, null).at("/document/sha256").asString()) + .isEqualTo(sha); + } + + @Test + void carriesTheBytesAsBase64ForBodyPayloads() throws IOException { + // Presets that attach or sign the document reference {{document.base64}}; without this the + // placeholder is unknown and the whole step fails at resolution time. + byte[] content = pdfBytes(document -> {}); + + String base64 = contextOf(content, "a.pdf", null, null).at("/document/base64").asString(); + + assertThat(Base64.getDecoder().decode(base64)).isEqualTo(content); + } + + @Test + void surfacesAnExistingPurviewLabel() throws IOException { + byte[] content = + pdfBytes( + document -> { + try { + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "2096f6a2-d2f7-48be-b329-b73aaa526e5d", + "Confidential", + TENANT, + AssignmentMethod.PRIVILEGED, + null, + null)); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); + + ObjectNode context = contextOf(content, "secret.pdf", null, null); + + assertThat(context.at("/sensitivityLabel/name").asString()).isEqualTo("Confidential"); + assertThat(context.at("/sensitivityLabel/siteId").asString()).isEqualTo(TENANT); + assertThat(context.at("/sensitivityLabel/method").asString()).isEqualTo("PRIVILEGED"); + assertThat(context.at("/sensitivityLabel/protected").asBoolean()).isFalse(); + } + + @Test + void surfacesTheClassifierVerdictAsJson() throws IOException { + byte[] content = + pdfBytes( + document -> + document.getDocumentInformation() + .setCustomMetadataValue( + PdfMetadataService.CLASSIFICATION_KEY, + "{\"label\":\"invoice\",\"confidence\":0.91}")); + + ObjectNode context = contextOf(content, "a.pdf", null, null); + + // Nested, not a JSON string, so {{classification.label}} resolves. + assertThat(context.at("/classification/label").asString()).isEqualTo("invoice"); + assertThat(context.at("/classification/confidence").asDouble()).isEqualTo(0.91); + } + + @Test + void omitsWhatIsAbsentRatherThanInventingIt() throws IOException { + ObjectNode context = contextOf(pdfBytes(document -> {}), "a.pdf", null, null); + + assertThat(context.has("sensitivityLabel")).isFalse(); + assertThat(context.has("classification")).isFalse(); + assertThat(context.at("/run/policyName").isNull()).isTrue(); + } + + @Test + void aNonPdfStillGetsTheBasics() { + byte[] content = "just text".getBytes(); + MockMultipartFile file = + new MockMultipartFile("fileInput", "notes.txt", "text/plain", content); + + ObjectNode context = DocumentContext.build(file, content, null, null, objectMapper); + + assertThat(context.at("/document/filename").asString()).isEqualTo("notes.txt"); + assertThat(context.at("/document/extension").asString()).isEqualTo("txt"); + assertThat(context.at("/document/sizeBytes").asInt()).isEqualTo(content.length); + assertThat(context.at("/document/sha256").asString()).hasSize(64); + // No PDF facts, and no exception either. + assertThat(context.at("/document/pageCount").isMissingNode()).isTrue(); + } + + @Test + void unparseableBytesClaimingToBeAPdfDoNotFailTheStep() { + byte[] content = "%PDF-1.7 but truncated".getBytes(); + MockMultipartFile file = + new MockMultipartFile("fileInput", "broken.pdf", "application/pdf", content); + + ObjectNode context = DocumentContext.build(file, content, null, null, objectMapper); + + assertThat(context.at("/document/sha256").asString()).hasSize(64); + assertThat(context.at("/document/pageCount").isMissingNode()).isTrue(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java new file mode 100644 index 0000000000..462c1a348f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallControllerLiveTest.java @@ -0,0 +1,615 @@ +package stirling.software.proprietary.integration.api; + +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.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.purview.PdfSensitivityLabels; +import stirling.software.proprietary.integration.purview.SensitivityLabel; +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; +import stirling.software.proprietary.service.AiToolResponseHeaders; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Drives the real {@link ExternalApiCallController} against a real HTTP server on loopback. + * + *

Everything below the connection lookup is genuine: a real PDF, real context extraction, real + * placeholder resolution, a real JDK HTTP client, and a real receiver that records exactly what + * arrived. Only {@link ApiConnectionResolver} is stubbed - resolving a connection means a database + * and an authorization check, which belong to their own tests. + * + *

The receiver is the point. Asserting what a third party actually received is the only way to + * know the document and its context left in the shape an integration expects; asserting our own + * intentions would pass just as happily with the bytes never leaving. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ExternalApiCallControllerLiveTest { + + @Mock private ApiConnectionResolver connectionResolver; + + private HttpServer server; + private String baseUrl; + private ExternalApiCallController controller; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ApplicationProperties properties = new ApplicationProperties(); + + /** What the receiver saw, so assertions are about the wire rather than our intentions. */ + private volatile String receivedBody; + + private volatile String receivedContentType; + private volatile String receivedMethod; + private final Map receivedHeaders = new LinkedHashMap<>(); + + @BeforeEach + void startReceiver() throws IOException { + // Loopback is exactly what the host guard blocks by default; an operator opts in for an + // on-prem integration, which is what a local receiver stands in for. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + // Records, then answers with a verdict - the DLP/scanner shape. + server.createContext( + "/v1/scan", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"verdict\":\"clean\",\"score\":0.02}" + .getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a different document - the converter shape. + server.createContext( + "/v1/convert", + exchange -> { + capture(exchange); + exchange.getResponseHeaders() + .add("Content-Disposition", "attachment; filename=\"converted.docx\""); + respond( + exchange, + 200, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "DOCX-BYTES".getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a link to the result - the async/large-file shape. + server.createContext( + "/v1/deferred", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + ("{\"status\":\"done\",\"data\":{\"downloadUrl\":\"" + + baseUrl + + "/files/result.pdf\"}}") + .getBytes(StandardCharsets.UTF_8)); + }); + + // Answers with a link on a host the connection never authorised. + server.createContext( + "/v1/evil", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"data\":{\"downloadUrl\":\"http://169.254.169.254/latest/meta-data/\"}}" + .getBytes(StandardCharsets.UTF_8)); + }); + + server.createContext( + "/files/result.pdf", + exchange -> + respond( + exchange, + 200, + "application/pdf", + "%PDF-1.7 fetched".getBytes(StandardCharsets.UTF_8))); + + // Answers with an archive - ConsignO's "PDF (single) or ZIP (multiple)" shape. + server.createContext( + "/v1/bundle", + exchange -> { + capture(exchange); + respond(exchange, 200, "application/zip", zip()); + }); + + server.createContext( + "/v1/reject", + exchange -> { + capture(exchange); + respond( + exchange, + 422, + "application/json", + "{\"error\":\"policy violation\"}".getBytes(StandardCharsets.UTF_8)); + }); + + // Cloudmersive's scan shape: HTTP 200 with the verdict in the body, clean or not. + server.createContext( + "/v1/clean", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"CleanResult\":true}".getBytes(StandardCharsets.UTF_8)); + }); + server.createContext( + "/v1/infected", + exchange -> { + capture(exchange); + respond( + exchange, + 200, + "application/json", + "{\"CleanResult\":false,\"FoundViruses\":[{\"VirusName\":\"EICAR\"}]}" + .getBytes(StandardCharsets.UTF_8)); + }); + + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + + ExternalApiCaller caller = + new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + controller = + new ExternalApiCallController( + connectionResolver, + caller, + objectMapper, + new TempFileManager(new TempFileRegistry(), properties), + properties); + } + + @AfterEach + void stopReceiver() { + server.stop(0); + } + + private void capture(HttpExchange exchange) throws IOException { + receivedMethod = exchange.getRequestMethod(); + receivedBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + receivedContentType = exchange.getRequestHeaders().getFirst("Content-Type"); + exchange.getRequestHeaders() + .forEach((name, values) -> receivedHeaders.put(name, values.get(0))); + } + + private static void respond(HttpExchange exchange, int status, String contentType, byte[] body) + throws IOException { + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private static byte[] zip() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("audit-trail.txt")); + zip.write("who signed what".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new ZipEntry("signed.pdf")); + zip.write("%PDF-1.7 signed".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return out.toByteArray(); + } + + /** A labelled, classified PDF, so the context has something real to carry. */ + private static MockMultipartFile pdf() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage()); + document.addPage(new PDPage()); + document.getDocumentInformation().setTitle("Q3 Claim"); + document.getDocumentInformation() + .setCustomMetadataValue( + PdfMetadataService.CLASSIFICATION_KEY, + "{\"label\":\"invoice\",\"confidence\":0.91}"); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "2096f6a2-d2f7-48be-b329-b73aaa526e5d", + "Confidential", + "cb46c030-1825-4e81-a295-151c039dbf02", + AssignmentMethod.PRIVILEGED, + null, + null)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return new MockMultipartFile( + "fileInput", "claim.pdf", "application/pdf", out.toByteArray()); + } + } + + private void connection(Map extra) { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.putAll(extra); + when(connectionResolver.resolve(eq(7L))).thenReturn(ApiConnectionSettings.from(config)); + when(connectionResolver.resolveConfig(eq(7L), any(IntegrationType.class))) + .thenReturn(config); + } + + /** + * The step's parameters, named. The controller takes seventeen positional arguments, which is + * unreadable and easy to mis-order at a call site; this lets each test state only what it + * varies. + */ + private final class Step { + private String path = "/v1/scan"; + private String method = "POST"; + private String bodyMode = "multipart"; + private String fileFieldName = "file"; + private String responseMode = "report"; + private String resultUrlPath; + private String resultUrlHeader; + private String responseSelect; + private String requireTrue; + private String fields; + private String bodyTemplate; + private String headers; + private boolean includeContext; + private boolean includeFile = true; + private String policyName; + private String runId; + + Step path(String v) { + path = v; + return this; + } + + Step method(String v) { + method = v; + return this; + } + + Step bodyMode(String v) { + bodyMode = v; + return this; + } + + Step responseMode(String v) { + responseMode = v; + return this; + } + + Step resultUrlPath(String v) { + resultUrlPath = v; + return this; + } + + Step responseSelect(String v) { + responseSelect = v; + return this; + } + + Step requireTrue(String v) { + requireTrue = v; + return this; + } + + Step fields(String v) { + fields = v; + return this; + } + + Step bodyTemplate(String v) { + bodyTemplate = v; + return this; + } + + Step headers(String v) { + headers = v; + return this; + } + + Step includeContext(boolean v) { + includeContext = v; + return this; + } + + Step run(String policy, String id) { + policyName = policy; + runId = id; + return this; + } + + ResponseEntity go() throws IOException { + return controller.call( + pdf(), + "7", + path, + method, + bodyMode, + fileFieldName, + responseMode, + resultUrlPath, + resultUrlHeader, + responseSelect, + requireTrue, + fields, + bodyTemplate, + headers, + includeContext, + includeFile, + policyName, + runId); + } + } + + private Step step() { + return new Step(); + } + + @Test + void sendsTheDocumentAndWhatWeKnowAboutItToTheReceiver() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/scan") + .fields( + "{\"sha256\":\"{{document.sha256}}\",\"label\":\"{{sensitivityLabel.name}}\"," + + "\"class\":\"{{classification.label}}\",\"pages\":\"{{document.pageCount}}\"}") + .includeContext(true) + .run("Outbound review", "run-42") + .go(); + + assertThat(receivedMethod).isEqualTo("POST"); + assertThat(receivedContentType).startsWith("multipart/form-data"); + // Fields the vendor asked for, filled from what Stirling already knew - no extra calls. + assertThat(receivedBody).contains("name=\"label\"").contains("Confidential"); + assertThat(receivedBody).contains("name=\"class\"").contains("invoice"); + assertThat(receivedBody).contains("name=\"pages\"").contains("2"); + assertThat(receivedBody).containsPattern("name=\"sha256\"[\\s\\S]{0,24}[0-9a-f]{64}"); + // The document itself, under the field name the vendor expects. + assertThat(receivedBody).contains("name=\"file\"; filename=\"claim.pdf\"").contains("%PDF"); + // The context, including which policy and run sent it. + assertThat(receivedBody) + .contains("stirlingContext") + .contains("Outbound review") + .contains("run-42"); + + JsonNode report = + objectMapper.readTree( + response.getHeaders().getFirst(AiToolResponseHeaders.TOOL_REPORT)); + assertThat(report.at("/status").asInt()).isEqualTo(200); + assertThat(report.at("/body/verdict").asString()).isEqualTo("clean"); + } + + @Test + void reportModeReturnsTheDocumentUntouched() throws IOException { + connection(Map.of()); + + ResponseEntity response = step().path("/v1/scan").go(); + + // Byte-for-byte: an inspecting call-out must not perturb what it inspected. + assertThat(response.getBody().getInputStream().readAllBytes()) + .startsWith("%PDF".getBytes()); + assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("claim.pdf"); + } + + @Test + void replaceModeAdoptsTheReturnedDocumentAndItsRealName() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/convert").bodyMode("binary").responseMode("replace").go(); + + assertThat(receivedContentType).isEqualTo("application/pdf"); + assertThat(receivedBody).startsWith("%PDF"); + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("DOCX-BYTES".getBytes(StandardCharsets.UTF_8)); + // Named for what came back, not what went out: a DOCX must not be called .pdf. + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("converted.docx"); + } + + @Test + void followsAResultUrlOnTheConnectionsOwnHost() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/deferred") + .responseMode("replace") + .resultUrlPath("data.downloadUrl") + .go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("%PDF-1.7 fetched".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void refusesAResultUrlTheConnectionNeverAuthorised() { + connection(Map.of()); + + // The URL is chosen by the remote service at run time; obeying it would be an SSRF. + assertThatThrownBy( + () -> + step().path("/v1/evil") + .responseMode("replace") + .resultUrlPath("data.downloadUrl") + .go()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void picksTheWantedFileOutOfAReturnedArchive() throws IOException { + connection(Map.of()); + + ResponseEntity response = + step().path("/v1/bundle").responseMode("replace").responseSelect("*.pdf").go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .isEqualTo("%PDF-1.7 signed".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void anUnselectedArchiveFailsRatherThanBecomingTheDocument() { + connection(Map.of()); + + // Handing a .zip to a step that expects a PDF fails later and more obscurely. + assertThatThrownBy(() -> step().path("/v1/bundle").responseMode("replace").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("responseSelect"); + } + + @Test + void aRejectedCallOutFailsTheStep() { + connection(Map.of()); + + // A policy that continued past a rejection would deliver documents the external system + // believes it never approved. + assertThatThrownBy(() -> step().path("/v1/reject").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 422") + .hasMessageContaining("policy violation"); + } + + @Test + void aCleanVerdictLetsTheDocumentThrough() throws IOException { + connection(Map.of()); + + // Cloudmersive answers HTTP 200 whether clean or not; the verdict is in the body. A clean + // result must pass the document through untouched. + ResponseEntity response = + step().path("/v1/clean").requireTrue("CleanResult").go(); + + assertThat(response.getBody().getInputStream().readAllBytes()) + .startsWith("%PDF".getBytes()); + } + + @Test + void anInfectedVerdictStopsTheRunEvenOnHttp200() { + connection(Map.of()); + + // The whole security proposition: HTTP 200 with CleanResult=false must NOT sail through. + assertThatThrownBy(() -> step().path("/v1/infected").requireTrue("CleanResult").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("CleanResult") + .hasMessageContaining("not true"); + } + + @Test + void aMissingVerdictFieldFailsClosed() { + connection(Map.of()); + + // /v1/scan answers {"verdict":"clean"} - it has no CleanResult field at all. A gate that + // cannot find its verdict must stop the run, not wave the document through. + assertThatThrownBy(() -> step().path("/v1/scan").requireTrue("CleanResult").go()) + .isInstanceOf(IOException.class) + .hasMessageContaining("not true"); + } + + @Test + void sendsAVendorShapedJsonBodyWithTheDocumentNestedInside() throws IOException { + connection(Map.of()); + + // ConsignO's submit shape: the PDF base64'd into documents[0].data. + step().path("/v1/scan") + .bodyMode("json") + .bodyTemplate( + "{\"name\":\"{{document.filename}}\",\"status\":1," + + "\"documents\":[{\"name\":\"{{document.filename}}\",\"data\":\"{{document.base64}}\"}]," + + "\"actions\":[{\"mode\":\"remote\",\"signer\":{\"type\":\"certifio\"}}]}") + .go(); + + assertThat(receivedContentType).isEqualTo("application/json"); + JsonNode sent = objectMapper.readTree(receivedBody); + assertThat(sent.at("/name").asString()).isEqualTo("claim.pdf"); + // Numbers keep their type; only strings are substituted. + assertThat(sent.at("/status").isNumber()).isTrue(); + assertThat(sent.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(Base64.getDecoder().decode(sent.at("/documents/0/data").asString())) + .startsWith("%PDF".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void appliesTheConnectionsCredentialAndTheStepsHeadersAndVerb() throws IOException { + connection(Map.of("authType", "BEARER", "token", "s3cr3t-token")); + + step().path("/v1/scan") + .method("PUT") + .headers("{\"X-Case-Id\":\"{{run.runId}}\"}") + .run(null, "run-99") + .go(); + + assertThat(receivedMethod).isEqualTo("PUT"); + assertThat(receivedHeaders.get("X-case-id")).isEqualTo("run-99"); + // The connection's credential, which the step never supplies or sees. + assertThat(receivedHeaders.get("Authorization")).isEqualTo("Bearer s3cr3t-token"); + } + + @Test + void aStepCannotAimTheCallAtAnotherHost() { + connection(Map.of()); + + assertThatThrownBy(() -> step().path("//evil.example/x").go()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be relative"); + } + + @Test + void notifyStyleCallOutSendsTheFactsWithoutTheDocument() throws IOException { + connection(Map.of()); + + Step notify = step().path("/v1/scan").bodyMode("json").includeContext(true); + notify.includeFile = false; + notify.run("Outbound review", "run-7").go(); + + JsonNode sent = objectMapper.readTree(receivedBody); + assertThat(sent.at("/document/filename").asString()).isEqualTo("claim.pdf"); + assertThat(sent.at("/run/policyName").asString()).isEqualTo("Outbound review"); + // No document: the point of a notification is the facts, not the bytes. + assertThat(sent.has("content")).isFalse(); + assertThat(receivedBody).doesNotContain("%PDF"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java new file mode 100644 index 0000000000..47aed03ae2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerAuthHeaderTest.java @@ -0,0 +1,145 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; + +/** + * Asserts the credential header {@link ExternalApiCaller} actually puts on the wire for each auth + * shape, against a real local server. + * + *

The interesting case is {@code headerPrefix}. Vendors disagree about the scheme in front of a + * token - PandaDoc wants {@code API-Key}, Rossum {@code token}, DeepL {@code DeepL-Auth-Key} - and + * without it every one of those presets would have to make the operator paste the scheme into the + * secret field, where a missing space silently becomes a 401. + */ +class ExternalApiCallerAuthHeaderTest { + + private HttpServer server; + private String baseUrl; + private final Map seen = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/ingest", + exchange -> { + seen.clear(); + exchange.getRequestHeaders() + .forEach( + (name, values) -> + seen.put( + name.toLowerCase(java.util.Locale.ROOT), + String.join(", ", values))); + exchange.getRequestBody().readAllBytes(); + byte[] body = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void headerAuthWithPrefixSendsSchemeAndToken() throws IOException { + post( + connection( + Map.of( + "authType", + "HEADER", + "headerName", + "Authorization", + "headerPrefix", + "API-Key", + "token", + "pd-secret"))); + + assertThat(seen).containsEntry("authorization", "API-Key pd-secret"); + } + + @Test + void headerAuthWithoutPrefixSendsTheBareToken() throws IOException { + post( + connection( + Map.of( + "authType", + "HEADER", + "headerName", + "x-api-key", + "token", + "sk-ant-secret"))); + + // No scheme invented: a vendor that wants the raw key must receive exactly that. + assertThat(seen).containsEntry("x-api-key", "sk-ant-secret"); + assertThat(seen).doesNotContainKey("authorization"); + } + + @Test + void bearerAuthIsUnaffectedByAPrefix() throws IOException { + // headerPrefix belongs to HEADER auth; BEARER must keep its own scheme regardless. + post( + connection( + Map.of( + "authType", + "BEARER", + "headerPrefix", + "API-Key", + "token", + "sk-secret"))); + + assertThat(seen).containsEntry("authorization", "Bearer sk-secret"); + } + + private void post(ApiConnectionSettings settings) throws IOException { + ExternalApiCaller.Response response = + caller().dispatch( + settings, + "POST", + "/ingest", + ExternalApiCaller.raw( + "application/json", "{}".getBytes(StandardCharsets.UTF_8)), + Map.of()); + assertThat(response.isSuccess()).isTrue(); + } + + private ApiConnectionSettings connection(Map options) { + Map config = new LinkedHashMap<>(options); + config.put("baseUrl", baseUrl); + return ApiConnectionSettings.from(config); + } + + private ExternalApiCaller caller() { + ApplicationProperties properties = new ApplicationProperties(); + // The server is on loopback, which is exactly what the guard blocks by default. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + return new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java new file mode 100644 index 0000000000..7cfbb8cbe4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiCallerTokenLoginTest.java @@ -0,0 +1,296 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Drives {@link ExternalApiCaller} against a real local HTTP server shaped like ConsignO Cloud's + * auth: credentials in headers plus a JSON body, and the token handed back only in the {@code + * X-Auth-Token} response header. + * + *

A real server rather than a mock, because what is being tested is the wire behaviour - that + * the token is found in a header, reused rather than re-fetched, and re-obtained on a 401. + */ +class ExternalApiCallerTokenLoginTest { + + private HttpServer server; + private String baseUrl; + private final AtomicInteger logins = new AtomicInteger(); + private final List> callHeaders = new ArrayList<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private volatile String issuedToken = "token-1"; + private volatile boolean rejectToken; + private volatile String workflowBody; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + server.createContext( + "/api/v1/auth/login", + exchange -> { + logins.incrementAndGet(); + String body = + new String( + exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8); + // The vendor authenticates the app by header and the user by body. + if (!"client-abc".equals(exchange.getRequestHeaders().getFirst("X-Client-Id")) + || !"client-xyz" + .equals( + exchange.getRequestHeaders() + .getFirst("X-Client-Secret")) + || !body.contains("\"password\":\"s3cr3t\"") + || !body.contains("\"tenantId\":\"acme\"")) { + respond(exchange, 401, "{}"); + return; + } + exchange.getResponseHeaders().add("X-Auth-Token", issuedToken); + respond(exchange, 200, "{\"msg\":\"ok\"}"); + }); + + server.createContext( + "/api/v1/documents", + exchange -> { + Map headers = new LinkedHashMap<>(); + exchange.getRequestHeaders() + .forEach((name, values) -> headers.put(name, values.get(0))); + callHeaders.add(headers); + String token = exchange.getRequestHeaders().getFirst("X-Auth-Token"); + if (rejectToken || token == null || !token.equals(issuedToken)) { + respond(exchange, 401, "{\"msg\":\"expired\"}"); + return; + } + respond( + exchange, + 201, + "{\"response\":{\"metadata\":{\"documentId\":\"doc-9\"}}}"); + }); + + server.createContext( + "/api/v1/workflows", + exchange -> { + if (!issuedToken.equals( + exchange.getRequestHeaders().getFirst("X-Auth-Token"))) { + respond(exchange, 401, "{\"msg\":\"expired\"}"); + return; + } + workflowBody = + new String( + exchange.getRequestBody().readAllBytes(), + StandardCharsets.UTF_8); + respond(exchange, 201, "{\"response\":{\"id\":\"wf-7\",\"status\":1}}"); + }); + + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/api/v1"; + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private ExternalApiCaller caller() { + ApplicationProperties properties = new ApplicationProperties(); + // The server is on loopback, which is exactly what the guard blocks by default. + properties.getPolicies().setAllowPrivateApiEndpoints(true); + return new ExternalApiCaller( + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(), + properties, + objectMapper); + } + + /** The ConsignO connection an operator would configure. */ + private ApiConnectionSettings consignoConnection() { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.put("authType", "TOKEN_LOGIN"); + config.put("loginPath", "/auth/login"); + config.put( + "loginBody", + Map.of("username", "api@acme.test", "password", "s3cr3t", "tenantId", "acme")); + config.put( + "loginHeaders", + Map.of("X-Client-Id", "client-abc", "X-Client-Secret", "client-xyz")); + config.put("tokenResponseHeader", "X-Auth-Token"); + config.put("tokenHeaderName", "X-Auth-Token"); + return ApiConnectionSettings.from(config); + } + + private ExternalApiCaller.Response upload(ExternalApiCaller caller) throws IOException { + return caller.postFile( + consignoConnection(), + "/documents", + "file", + "contract.pdf", + "application/pdf", + "%PDF-1.7".getBytes(StandardCharsets.UTF_8), + Map.of()); + } + + @Test + void logsInAndSendsTheTokenFromTheResponseHeader() throws IOException { + ExternalApiCaller.Response response = upload(caller()); + + assertThat(response.status()).isEqualTo(201); + assertThat(response.bodyAsText()).contains("doc-9"); + assertThat(logins.get()).isEqualTo(1); + // The token was found in a response header and presented on the next call. + assertThat(callHeaders) + .singleElement() + .extracting(h -> h.get("X-auth-token")) + .isEqualTo("token-1"); + } + + @Test + void reusesTheTokenAcrossCallsRatherThanLoggingInPerDocument() throws IOException { + ExternalApiCaller caller = caller(); + upload(caller); + upload(caller); + upload(caller); + + // A 100-document policy must not perform 100 logins. + assertThat(logins.get()).isEqualTo(1); + assertThat(callHeaders).hasSize(3); + } + + @Test + void reAuthenticatesOnceWhenTheTokenIsRejected() throws IOException { + ExternalApiCaller caller = caller(); + upload(caller); + assertThat(logins.get()).isEqualTo(1); + + // The vendor expires the token early and starts issuing a new one. + issuedToken = "token-2"; + ExternalApiCaller.Response response = upload(caller); + + assertThat(response.status()).isEqualTo(201); + assertThat(logins.get()).isEqualTo(2); + assertThat(callHeaders).last().extracting(h -> h.get("X-auth-token")).isEqualTo("token-2"); + } + + @Test + void aPersistent401SurfacesRatherThanLoopingForever() throws IOException { + rejectToken = true; + + ExternalApiCaller.Response response = upload(caller()); + + assertThat(response.status()).isEqualTo(401); + // Exactly one retry: the initial login plus one re-auth, then give up. + assertThat(logins.get()).isEqualTo(2); + } + + /** + * The whole ConsignO submit, as an operator would configure it: log in, then post their real + * workflow shape with the PDF base64'd into {@code documents[0].data}. Their API takes the + * document inline, so {@code POST /documents} is not needed and the submit is a single call - + * which is what brings it within reach of the generic step. + */ + @Test + void submitsAConsignoSignatureWorkflowEndToEnd() throws IOException { + byte[] pdf = "%PDF-1.7 contract".getBytes(StandardCharsets.UTF_8); + ObjectNode context = objectMapper.createObjectNode(); + ObjectNode document = context.putObject("document"); + document.put("filename", "contract.pdf"); + document.put("base64", Base64.getEncoder().encodeToString(pdf)); + + String template = + """ + { + "name": "{{document.filename}}", + "status": 1, + "documents": [ + {"name": "{{document.filename}}", "data": "{{document.base64}}"} + ], + "actions": [ + {"mode":"remote","ref":"1", + "signer":{"type":"certifio","email":"notary@example.test","lang":"en"}} + ] + } + """; + JsonNode body = Placeholders.resolveTree(objectMapper.readTree(template), context); + + ExternalApiCaller.Response response = + caller().dispatch( + consignoConnection(), + "POST", + "/workflows", + ExternalApiCaller.raw( + "application/json", objectMapper.writeValueAsBytes(body)), + Map.of()); + + assertThat(response.status()).isEqualTo(201); + // The workflow id the vendor hands back - the thing a later fetch would need, and which a + // step currently has no way to carry to the next step. + assertThat(objectMapper.readTree(response.bodyAsText()).at("/response/id").asString()) + .isEqualTo("wf-7"); + assertThat(logins.get()).isEqualTo(1); + + // The document really arrived, nested where ConsignO expects it. + JsonNode received = objectMapper.readTree(workflowBody); + assertThat(received.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(Base64.getDecoder().decode(received.at("/documents/0/data").asString())) + .isEqualTo(pdf); + } + + @Test + void badCredentialsFailTheStepWithoutEchoingThem() { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", baseUrl); + config.put("authType", "TOKEN_LOGIN"); + config.put("loginPath", "/auth/login"); + config.put("loginBody", Map.of("username", "api@acme.test", "password", "wrong")); + config.put("loginHeaders", Map.of("X-Client-Id", "client-abc", "X-Client-Secret", "nope")); + config.put("tokenResponseHeader", "X-Auth-Token"); + config.put("tokenHeaderName", "X-Auth-Token"); + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + + assertThatThrownBy( + () -> + caller().postFile( + settings, + "/documents", + "file", + "c.pdf", + "application/pdf", + "%PDF".getBytes(StandardCharsets.UTF_8), + Map.of())) + .isInstanceOf(IOException.class) + .hasMessageContaining("returned HTTP 401") + // The login body is echoed by some vendors; the message must not carry it onward. + .hasMessageNotContaining("wrong"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java new file mode 100644 index 0000000000..e03b330095 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ExternalApiPathsTest.java @@ -0,0 +1,135 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * {@link ExternalApiPaths} is the control that stops the external-API step becoming an SSRF + * primitive, so these lean on the ways a step author might try to leave the connection's base URL. + */ +class ExternalApiPathsTest { + + private static final URI BASE = URI.create("https://api.example.com/v1"); + + @Nested + @DisplayName("resolves paths under the base") + class Resolves { + + @Test + void appendsARelativePath() { + assertThat(ExternalApiPaths.resolve(BASE, "/scan")) + .isEqualTo(URI.create("https://api.example.com/v1/scan")); + } + + @Test + void addsTheLeadingSlashWhenOmitted() { + assertThat(ExternalApiPaths.resolve(BASE, "scan")) + .isEqualTo(URI.create("https://api.example.com/v1/scan")); + } + + @Test + void blankPathIsTheBaseItself() { + assertThat(ExternalApiPaths.resolve(BASE, " ")).isEqualTo(BASE); + assertThat(ExternalApiPaths.resolve(BASE, null)).isEqualTo(BASE); + } + + @Test + void keepsAQueryString() { + assertThat(ExternalApiPaths.resolve(BASE, "/scan?mode=strict")) + .isEqualTo(URI.create("https://api.example.com/v1/scan?mode=strict")); + } + + @Test + void allowsATraversalThatStaysUnderTheBase() { + // "/v1/a/../b" normalises to "/v1/b", which is still under the base. + assertThat(ExternalApiPaths.resolve(BASE, "/a/../b")) + .isEqualTo(URI.create("https://api.example.com/v1/b")); + } + + @Test + void baseWithNoPathAcceptsAnyPath() { + assertThat(ExternalApiPaths.resolve(URI.create("https://api.example.com"), "/scan")) + .isEqualTo(URI.create("https://api.example.com/scan")); + } + + @Test + void keepsAnEncodedSlashFromASubstitutedValue() { + // Placeholders percent-encodes what it substitutes, so a filename containing '/' + // arrives as %2F. That is data inside one segment and must survive. + assertThat(ExternalApiPaths.resolve(BASE, "/docs/my%2Ffile.pdf")) + .isEqualTo(URI.create("https://api.example.com/v1/docs/my%2Ffile.pdf")); + } + } + + @Nested + @DisplayName("refuses to leave the base") + class Refuses { + + @Test + void protocolRelativeUrlCannotChangeHost() { + // The reason URI.resolve is not used: it would yield https://evil.example/x here. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "//evil.example/x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be relative"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "https://evil.example/x", + "http://evil.example/x", + "HTTPS://evil.example/x", + "file:///etc/passwd" + }) + void absoluteUrlIsRejected(String path) { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, path)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void traversalAboveTheBasePathIsRejected() { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/../admin")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void percentEncodedTraversalIsRejected() { + // normalize() would not decode these, so the target server would do the escaping. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/%2e%2e/admin")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("percent-encode"); + } + + @ParameterizedTest + @ValueSource(strings = {"/scan\r\nX-Injected: 1", "/scan\nfoo", "/sc an", "/scan\\..\\x"}) + void requestSplittingCharactersAreRejected(String path) { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, path)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @Test + void siblingPathThatMerelySharesAPrefixIsRejected() { + // "/v1betray" starts with "/v1" textually but is a different resource tree. + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/../v1betray/x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void fragmentIsRejected() { + assertThatThrownBy(() -> ExternalApiPaths.resolve(BASE, "/scan#frag")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fragment"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java new file mode 100644 index 0000000000..c059de14d4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/MultipartBodyTest.java @@ -0,0 +1,111 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * The name/value asymmetry here is easy to get wrong in either direction: too lax on names is + * header injection, too strict on values silently rejects ordinary JSON. + */ +class MultipartBodyTest { + + private static String render(MultipartBody body) throws IOException { + // The publisher is what actually goes on the wire. + java.net.http.HttpRequest.BodyPublisher publisher = body.build(); + StringBuilder out = new StringBuilder(); + publisher.subscribe( + new java.util.concurrent.Flow.Subscriber<>() { + @Override + public void onSubscribe(java.util.concurrent.Flow.Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(java.nio.ByteBuffer item) { + byte[] bytes = new byte[item.remaining()]; + item.get(bytes); + out.append(new String(bytes, StandardCharsets.UTF_8)); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onComplete() {} + }); + return out.toString(); + } + + @Test + void carriesAJsonValueThroughUntouched() throws IOException { + // Regression: values were once checked like headers, which rejected every JSON value — + // including the auto-populated context, so includeContext could never be sent. + String json = "{\"document\":{\"title\":\"Q3 \\\"final\\\"\"},\"n\":2}"; + MultipartBody body = new MultipartBody(); + body.addField("stirlingContext", json); + + assertThat(render(body)).contains(json); + } + + @Test + void carriesAValueWithNewlinesAndBackslashes() throws IOException { + MultipartBody body = new MultipartBody(); + body.addField("notes", "line one\nline two\\end"); + + assertThat(render(body)).contains("line one\nline two\\end"); + } + + @Test + void writesTheDocumentUnderItsFieldNameAndFilename() throws IOException { + MultipartBody body = new MultipartBody(); + body.addFields(Map.of("policy", "strict")); + body.addFile( + "file", + "claim.pdf", + "application/pdf", + "%PDF-1.7".getBytes(StandardCharsets.UTF_8)); + + String rendered = render(body); + assertThat(rendered).contains("name=\"policy\"").contains("strict"); + assertThat(rendered) + .contains("name=\"file\"; filename=\"claim.pdf\"") + .contains("Content-Type: application/pdf") + .contains("%PDF-1.7"); + assertThat(body.contentType()).startsWith("multipart/form-data; boundary=StirlingBoundary"); + } + + @ParameterizedTest + @ValueSource(strings = {"na\"me", "na\rme", "na\nme", "na\\me"}) + void refusesAFieldNameThatCouldForgeItsOwnHeaders(String name) { + MultipartBody body = new MultipartBody(); + + assertThatThrownBy(() -> body.addField(name, "v")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @ParameterizedTest + @ValueSource(strings = {"a\".pdf", "a\r.pdf", "a\n.pdf"}) + void refusesAFilenameThatCouldForgeItsOwnHeaders(String filename) { + MultipartBody body = new MultipartBody(); + + assertThatThrownBy(() -> body.addFile("file", filename, "application/pdf", new byte[] {1})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("illegal character"); + } + + @Test + void eachBodyGetsItsOwnBoundary() { + // A value cannot end its own part because it cannot know the boundary in advance. + assertThat(new MultipartBody().contentType()) + .isNotEqualTo(new MultipartBody().contentType()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java new file mode 100644 index 0000000000..3d91d0230f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTemplateTest.java @@ -0,0 +1,110 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Base64; + +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Body templating is what decides whether a vendor with a nested payload needs bespoke code, so the + * headline case here is ConsignO Cloud's real {@code POST /workflows} shape. + */ +class PlaceholdersTemplateTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ObjectNode context() { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + document.put("filename", "contract.pdf"); + document.put("base64", Base64.getEncoder().encodeToString("%PDF-1.7".getBytes())); + document.put("pageCount", 4); + root.putObject("run").put("policyName", "Signature run"); + root.putObject("sensitivityLabel").put("name", "Confidential"); + return root; + } + + private JsonNode resolve(String template) { + return Placeholders.resolveTree(objectMapper.readTree(template), context()); + } + + @Test + void buildsConsignOsWorkflowPayload() { + // Lifted from the ConsignO Cloud API reference: the document rides base64 in + // documents[0].data, and `certifio` is the Notarius professional-certificate signer. + String template = + """ + { + "name": "{{document.filename}}", + "status": 1, + "documents": [ + {"name": "{{document.filename}}", "data": "{{document.base64}}"} + ], + "actions": [ + { + "mode": "remote", + "ref": "1", + "signer": { + "type": "certifio", + "email": "notary@example.test", + "lang": "en" + } + } + ] + } + """; + + JsonNode body = resolve(template); + + assertThat(body.at("/name").asString()).isEqualTo("contract.pdf"); + // Numbers and booleans keep their type; only strings are substituted. + assertThat(body.at("/status").isNumber()).isTrue(); + assertThat(body.at("/status").asInt()).isEqualTo(1); + assertThat(body.at("/documents/0/name").asString()).isEqualTo("contract.pdf"); + assertThat(new String(Base64.getDecoder().decode(body.at("/documents/0/data").asString()))) + .isEqualTo("%PDF-1.7"); + assertThat(body.at("/actions/0/signer/type").asString()).isEqualTo("certifio"); + assertThat(body.at("/actions/0/ref").asString()).isEqualTo("1"); + } + + @Test + void resolvesInsideNestedObjectsAndArrays() { + JsonNode body = + resolve( + "{\"a\":{\"b\":[{\"c\":\"{{document.filename}}\"}," + + "\"{{run.policyName}}\"]}}"); + + assertThat(body.at("/a/b/0/c").asString()).isEqualTo("contract.pdf"); + assertThat(body.at("/a/b/1").asString()).isEqualTo("Signature run"); + } + + @Test + void leavesNonStringsAlone() { + JsonNode body = resolve("{\"n\":3,\"b\":true,\"z\":null,\"arr\":[1,2]}"); + + assertThat(body.at("/n").asInt()).isEqualTo(3); + assertThat(body.at("/b").asBoolean()).isTrue(); + assertThat(body.at("/z").isNull()).isTrue(); + assertThat(body.at("/arr/1").asInt()).isEqualTo(2); + } + + @Test + void substitutesWithinSurroundingText() { + JsonNode body = resolve("{\"subject\":\"{{run.policyName}}: {{document.filename}}\"}"); + + assertThat(body.at("/subject").asString()).isEqualTo("Signature run: contract.pdf"); + } + + @Test + void aTypoInATemplateIsAnErrorNotASilentlyEmptyPayload() { + assertThatThrownBy(() -> resolve("{\"x\":\"{{document.flename}}\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown placeholder"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java new file mode 100644 index 0000000000..cd82e7997f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/PlaceholdersTest.java @@ -0,0 +1,127 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.integration.api.Placeholders.Escaping; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +class PlaceholdersTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ObjectNode context() { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode document = root.putObject("document"); + document.put("filename", "invoice.pdf"); + document.put("sha256", "abc123"); + document.put("pageCount", 3); + document.putNull("title"); + ObjectNode label = root.putObject("sensitivityLabel"); + label.put("name", "Confidential"); + root.putObject("run").put("policyName", "Outbound review"); + return root; + } + + @Test + void substitutesADottedPath() { + assertThat(Placeholders.resolve("{{document.filename}}", context(), Escaping.NONE)) + .isEqualTo("invoice.pdf"); + } + + @Test + void substitutesSeveralWithSurroundingText() { + assertThat( + Placeholders.resolve( + "{{document.filename}} ({{document.pageCount}}p) is" + + " {{sensitivityLabel.name}}", + context(), + Escaping.NONE)) + .isEqualTo("invoice.pdf (3p) is Confidential"); + } + + @Test + void toleratesWhitespaceInsideBraces() { + assertThat(Placeholders.resolve("{{ document.sha256 }}", context(), Escaping.NONE)) + .isEqualTo("abc123"); + } + + @Test + void aNullValueRendersEmptyNotTheWordNull() { + // "null" in a vendor's field would read as a value rather than an absence. + assertThat(Placeholders.resolve("[{{document.title}}]", context(), Escaping.NONE)) + .isEqualTo("[]"); + } + + @Test + void textWithNoPlaceholderIsUntouched() { + assertThat(Placeholders.resolve("/scan", context(), Escaping.NONE)).isEqualTo("/scan"); + assertThat(Placeholders.resolve(null, context(), Escaping.NONE)).isNull(); + } + + @Test + void anUnknownPathIsAnErrorRatherThanAnEmptyValue() { + // A typo that silently sent "" could mean an external system files a document wrongly. + assertThatThrownBy( + () -> Placeholders.resolve("{{document.nope}}", context(), Escaping.NONE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown placeholder"); + assertThatThrownBy(() -> Placeholders.resolve("{{nope.at.all}}", context(), Escaping.NONE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void anObjectRendersAsJson() { + assertThat(Placeholders.resolve("{{sensitivityLabel}}", context(), Escaping.NONE)) + .isEqualTo("{\"name\":\"Confidential\"}"); + } + + @Test + void pathEscapingEncodesSeparatorsButNotDots() { + ObjectNode context = context(); + context.putObject("x").put("weird", "a/b c.pdf"); + + // The dot survives (it is unreserved); the slash and space cannot pass as structure. + assertThat(Placeholders.resolve("{{x.weird}}", context, Escaping.URL_PATH)) + .isEqualTo("a%2Fb%20c.pdf"); + } + + @Test + void aTraversalInAValueIsNeutralisedRatherThanObeyed() { + ObjectNode context = context(); + context.putObject("x").put("nasty", "../../admin"); + + // Encoding the separators leaves one inert segment, so there is no traversal left to + // normalise: the request stays under the base and the value arrives as data. + String resolved = Placeholders.resolve("/docs/{{x.nasty}}", context, Escaping.URL_PATH); + assertThat(resolved).isEqualTo("/docs/..%2F..%2Fadmin"); + + assertThat(ExternalApiPaths.resolve(URI.create("https://api.example.com/v1"), resolved)) + .isEqualTo(URI.create("https://api.example.com/v1/docs/..%2F..%2Fadmin")); + } + + @Test + void aTraversalWrittenIntoTheTemplateItselfIsStillRejected() { + // The operator's own text is not encoded, so a literal ".." normalises and the base check + // sees it. This is why dots are deliberately left unencoded above. + assertThatThrownBy( + () -> + ExternalApiPaths.resolve( + URI.create("https://api.example.com/v1"), "/docs/../../x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("escapes"); + } + + @Test + void detectsWhetherTextReferencesAnything() { + assertThat(Placeholders.hasPlaceholder("{{a.b}}")).isTrue(); + assertThat(Placeholders.hasPlaceholder("plain")).isFalse(); + assertThat(Placeholders.hasPlaceholder(null)).isFalse(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java new file mode 100644 index 0000000000..a46cd115a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ResultUrlsTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.integration.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import stirling.software.common.model.ApplicationProperties; + +/** + * A result URL is picked by the remote service at run time, so these lean on the ways a hostile or + * compromised integration might use that to aim a server-side fetch somewhere it should not go. + */ +class ResultUrlsTest { + + private final ApplicationProperties properties = new ApplicationProperties(); + + private ApiConnectionSettings connection(List resultUrlHosts) { + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "https://api.vendor.example/v1"); + if (resultUrlHosts != null) { + config.put("resultUrlHosts", resultUrlHosts); + } + return ApiConnectionSettings.from(config); + } + + // The host-matching rule is asserted directly: validate() also resolves the host, which fails + // closed, so it cannot be exercised against reserved .example names without real DNS. + + @Test + void allowsTheConnectionsOwnHostWithoutBeingDeclared() { + assertThat(ResultUrls.isAllowedHost(connection(null), "api.vendor.example")).isTrue(); + } + + @Test + void allowsADeclaredResultHost() { + // The common real case: the API answers on one host, the file lives on a CDN. + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("cdn.vendor.example")), "cdn.vendor.example")) + .isTrue(); + } + + @Test + void allowsASubdomainOfADeclaredHost() { + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("vendor.example")), "files.eu.vendor.example")) + .isTrue(); + } + + @Test + void hostMatchingIsCaseInsensitive() { + assertThat( + ResultUrls.isAllowedHost( + connection(List.of("CDN.Vendor.Example")), "cdn.vendor.example")) + .isTrue(); + } + + @Test + void anUnresolvableHostIsRefusedRatherThanAssumedSafe() { + // Fail closed: if we cannot see where a name points, we cannot say it is not internal. + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(null), + "https://api.vendor.example/files/signed.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unable to resolve"); + } + + @Test + void fetchesFromAnAllowedHostThatResolves() { + properties.getPolicies().setAllowPrivateApiEndpoints(true); + Map config = new LinkedHashMap<>(); + config.put("baseUrl", "http://127.0.0.1:9000/v1"); + ApiConnectionSettings settings = ApiConnectionSettings.from(config); + + assertThat( + ResultUrls.validate( + settings, "http://127.0.0.1:9000/files/signed.pdf", properties)) + .isEqualTo(URI.create("http://127.0.0.1:9000/files/signed.pdf")); + } + + @Test + void refusesAnUndeclaredHost() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(null), "https://evil.example/x.pdf", properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void refusesAHostThatMerelyEndsWithADeclaredOne() { + // "evilvendor.example" must not be admitted by an entry of "vendor.example". + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("vendor.example")), + "https://evilvendor.example/x.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not allow"); + } + + @Test + void refusesTheCloudMetadataServiceEvenIfDeclared() { + // The headline SSRF: an integration answering with the metadata address. + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("169.254.169.254")), + "http://169.254.169.254/latest/meta-data/iam/", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("private/link-local"); + } + + @Test + void refusesLoopbackEvenIfDeclared() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("localhost")), + "http://localhost:8080/admin", + properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void anOperatorCanOptInToPrivateResultHostsForOnPrem() { + properties.getPolicies().setAllowPrivateApiEndpoints(true); + + assertThat( + ResultUrls.validate( + connection(List.of("localhost")), + "http://localhost:8080/files/signed.pdf", + properties)) + .hasHost("localhost"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "file:///etc/passwd", + "jar:file:///tmp/x.jar!/y", + "gopher://evil.example/x", + "ftp://evil.example/x" + }) + void refusesNonHttpSchemes(String url) { + // A URL fetch that accepts file: is a local file read. + assertThatThrownBy(() -> ResultUrls.validate(connection(null), url, properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void refusesCredentialsEmbeddedInTheUrl() { + assertThatThrownBy( + () -> + ResultUrls.validate( + connection(List.of("cdn.vendor.example")), + "https://user:pw@cdn.vendor.example/x.pdf", + properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("credentials"); + } + + @Test + void refusesGarbage() { + assertThatThrownBy(() -> ResultUrls.validate(connection(null), "not a url", properties)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void resultUrlHostsMustBeBareHostnames() { + // A URL or wildcard here reads as broader than it is. + assertThatThrownBy(() -> connection(List.of("https://cdn.vendor.example/x"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bare hostnames"); + assertThatThrownBy(() -> connection(List.of("*.vendor.example"))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java new file mode 100644 index 0000000000..7e563c371a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/purview/PdfSensitivityLabelsTest.java @@ -0,0 +1,282 @@ +package stirling.software.proprietary.integration.purview; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.integration.purview.SensitivityLabel.AssignmentMethod; + +/** + * Exercises the label round-trip against real PDFBox documents, including a save/reload so the + * assertions reflect what actually lands on disk rather than in-memory state. + */ +class PdfSensitivityLabelsTest { + + private static final String LABEL_ID = "2096f6a2-d2f7-48be-b329-b73aaa526e5d"; + private static final String TENANT = "cb46c030-1825-4e81-a295-151c039dbf02"; + + private static PDDocument newDocument() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage()); + return document; + } + + private static PDDocument saveAndReload(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + document.close(); + return Loader.loadPDF(new ByteArrayInputStream(out.toByteArray()).readAllBytes()); + } + + private static String xmpString(PDDocument document) throws IOException { + PDMetadata metadata = document.getDocumentCatalog().getMetadata(); + if (metadata == null) { + return ""; + } + try (InputStream is = metadata.exportXMPMetadata()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static SensitivityLabel confidential() { + return new SensitivityLabel( + LABEL_ID, + "Confidential", + TENANT, + AssignmentMethod.STANDARD, + Instant.parse("2026-07-17T10:15:30Z"), + SensitivityLabel.CONTENT_BITS_FOOTER); + } + + @Test + void appliedLabelSurvivesSaveAndReload() throws IOException { + PDDocument document = newDocument(); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + SensitivityLabel read = PdfSensitivityLabels.read(reloaded).orElseThrow(); + assertThat(read.labelId()).isEqualTo(LABEL_ID); + assertThat(read.name()).isEqualTo("Confidential"); + assertThat(read.siteId()).isEqualTo(TENANT); + assertThat(read.method()).isEqualTo(AssignmentMethod.STANDARD); + assertThat(read.setDate()).isEqualTo(Instant.parse("2026-07-17T10:15:30Z")); + assertThat(read.contentBits()).isEqualTo(SensitivityLabel.CONTENT_BITS_FOOTER); + } + } + + @Test + void writesTheDocumentedKeyNamesIntoTheInfoDictionary() throws IOException { + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply(document, confidential()); + + var info = document.getDocumentInformation(); + String prefix = "MSIP_Label_" + LABEL_ID + "_"; + assertThat(info.getCustomMetadataValue(prefix + "Enabled")).isEqualTo("true"); + assertThat(info.getCustomMetadataValue(prefix + "SiteId")).isEqualTo(TENANT); + assertThat(info.getCustomMetadataValue(prefix + "Method")).isEqualTo("Standard"); + assertThat(info.getCustomMetadataValue(prefix + "Name")).isEqualTo("Confidential"); + assertThat(info.getCustomMetadataValue(prefix + "ContentBits")).isEqualTo("2"); + // Extended ISO 8601, as the MIP contract specifies. + assertThat(info.getCustomMetadataValue(prefix + "SetDate")) + .isEqualTo("2026-07-17T10:15:30+0000"); + } + } + + @Test + void readsALabelPresentOnlyInTheInfoDictionary() throws IOException { + // What a third-party labeller may leave behind: no XMP copy at all. + try (PDDocument document = newDocument()) { + var info = document.getDocumentInformation(); + String prefix = "MSIP_Label_" + LABEL_ID + "_"; + info.setCustomMetadataValue(prefix + "Enabled", "true"); + info.setCustomMetadataValue(prefix + "SiteId", TENANT); + info.setCustomMetadataValue(prefix + "Name", "Secret"); + + SensitivityLabel read = PdfSensitivityLabels.read(document).orElseThrow(); + assertThat(read.name()).isEqualTo("Secret"); + assertThat(read.method()).isNull(); + } + } + + @Test + void unlabelledDocumentReadsAsEmpty() throws IOException { + try (PDDocument document = newDocument()) { + assertThat(PdfSensitivityLabels.read(document)).isEmpty(); + } + } + + @Test + void enabledFalseIsNotALabel() throws IOException { + try (PDDocument document = newDocument()) { + var info = document.getDocumentInformation(); + info.setCustomMetadataValue("MSIP_Label_" + LABEL_ID + "_Enabled", "false"); + info.setCustomMetadataValue("MSIP_Label_" + LABEL_ID + "_SiteId", TENANT); + + assertThat(PdfSensitivityLabels.read(document)).isEmpty(); + } + } + + @Test + void relabellingReplacesTheSameTenantsLabel() throws IOException { + // "An object can only have one label from the same organization." + String otherLabel = "11111111-2222-3333-4444-555555555555"; + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply(document, confidential()); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + otherLabel, "Public", TENANT, AssignmentMethod.PRIVILEGED, null, null)); + + assertThat(PdfSensitivityLabels.readAll(document)) + .singleElement() + .satisfies( + label -> { + assertThat(label.labelId()).isEqualTo(otherLabel); + assertThat(label.name()).isEqualTo("Public"); + }); + } + } + + @Test + void aDifferentTenantsLabelIsLeftAlone() throws IOException { + String foreignTenant = "99999999-8888-7777-6666-555555555555"; + try (PDDocument document = newDocument()) { + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "Foreign", + foreignTenant, + null, + null, + null)); + PdfSensitivityLabels.apply(document, confidential()); + + assertThat(PdfSensitivityLabels.readAll(document)) + .hasSize(2) + .extracting(SensitivityLabel::siteId) + .containsExactlyInAnyOrder(foreignTenant, TENANT); + } + } + + @Test + void aDifferentTenantsLabelStaysInTheXmpSurfaceToo() throws IOException { + String foreignLabel = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + PDDocument document = newDocument(); + PdfSensitivityLabels.apply( + document, + new SensitivityLabel( + foreignLabel, + "Foreign", + "99999999-8888-7777-6666-555555555555", + null, + null, + null)); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + // The foreign label must survive on the XMP copy, not only in the info dictionary: + // re-labelling replaces this tenant's labels, not everyone else's. + String xmp = xmpString(reloaded); + assertThat(xmp).contains("MSIP_Label_" + foreignLabel + "_"); + assertThat(xmp).contains("MSIP_Label_" + LABEL_ID + "_"); + } + } + + @Test + void clearRemovesEveryLabel() throws IOException { + PDDocument document = newDocument(); + PdfSensitivityLabels.apply(document, confidential()); + PdfSensitivityLabels.clear(document); + + try (PDDocument reloaded = saveAndReload(document)) { + assertThat(PdfSensitivityLabels.readAll(reloaded)).isEmpty(); + } + } + + @Test + void refusesALabelThatClaimsEncryption() throws IOException { + try (PDDocument document = newDocument()) { + SensitivityLabel encrypting = + new SensitivityLabel( + LABEL_ID, + "Highly Confidential", + TENANT, + AssignmentMethod.STANDARD, + null, + SensitivityLabel.CONTENT_BITS_ENCRYPT); + + // Marking content as protected without protecting it would mislead every reader. + assertThatThrownBy(() -> PdfSensitivityLabels.apply(document, encrypting)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot protect"); + } + } + + @Test + void preservesUnrelatedXmpAndInfoMetadata() throws IOException { + PDDocument document = newDocument(); + document.getDocumentInformation().setAuthor("Anthony"); + document.getDocumentInformation().setCustomMetadataValue("StirlingPDFClassification", "{}"); + PdfSensitivityLabels.apply(document, confidential()); + + try (PDDocument reloaded = saveAndReload(document)) { + assertThat(reloaded.getDocumentInformation().getAuthor()).isEqualTo("Anthony"); + assertThat( + reloaded.getDocumentInformation() + .getCustomMetadataValue("StirlingPDFClassification")) + .isEqualTo("{}"); + assertThat(PdfSensitivityLabels.read(reloaded)).isPresent(); + } + } + + @Test + void labelValuesAreCappedAtTheDocumentedLength() { + SensitivityLabel longName = + new SensitivityLabel(LABEL_ID, "x".repeat(400), TENANT, null, null, null); + assertThat(longName.toMetadata().get("MSIP_Label_" + LABEL_ID + "_Name")) + .hasSize(SensitivityLabel.MAX_VALUE_LENGTH); + } + + @Test + void labelRequiresIdAndTenant() { + assertThatThrownBy(() -> new SensitivityLabel(null, "n", TENANT, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new SensitivityLabel(LABEL_ID, "n", " ", null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsALabelIdThatIsNotAGuid() { + // labelId is written into XMP/info key names verbatim; a space or markup char must not + // pass, + // or it would corrupt or inject the metadata packet it lands in. + assertThatThrownBy( + () -> + new SensitivityLabel( + "not a guid", "Public", TENANT, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new SensitivityLabel( + "-2222-3333-4444-5555555555", + "Public", + TENANT, + null, + null, + null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index fbfef15721..90842612d1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -55,6 +55,9 @@ class IntegrationConfigServiceTest { @Mock private IntegrationConfigValidator validator; @Mock private IntegrationConfigUsageCheck usageCheck; + private final stirling.software.common.model.ApplicationProperties applicationProperties = + new stirling.software.common.model.ApplicationProperties(); + private IntegrationConfigService service; @BeforeEach @@ -65,6 +68,7 @@ class IntegrationConfigServiceTest { ownership, secretMasker, grantRepository, + applicationProperties, List.of(validator), List.of(usageCheck)); } @@ -72,21 +76,107 @@ class IntegrationConfigServiceTest { @Test void createRejectsAConfigItsTypeValidatorRefuses() { when(secretMasker.sanitize(any())).thenReturn(Map.of()); - when(validator.type()).thenReturn(IntegrationType.API); - org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'")) + when(validator.type()).thenReturn(IntegrationType.MCP); + org.mockito.Mockito.doThrow(new IllegalArgumentException("mcp config needs a 'url'")) .when(validator) .validate(any()); + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.MCP, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void anAdminCanAuthorACustomApiIntegration() { + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(ownership.isAdmin(any())).thenReturn(true); + + IntegrationConfig created = + service.create(request(IntegrationType.API, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); + } + + @Test + void aNonAdminCannotAuthorACustomApiIntegration() { + // A custom integration names its own host and body, so it can aim the server anywhere; + // that is admin authoring power, not self-serve config like a vendor preset. + when(ownership.isAdmin(any())).thenReturn(false); + assertThatThrownBy( () -> service.create( request(IntegrationType.API, OwnerScope.USER, null), user(7))) .isInstanceOf(ResponseStatusException.class) - .satisfies( - e -> - assertThat(((ResponseStatusException) e).getStatusCode()) - .isEqualTo(HttpStatus.BAD_REQUEST)); + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void theOperatorCanWithdrawCustomApiAuthoringEntirely() { + applicationProperties.getPolicies().setAllowCustomApiIntegrations(false); + + // Off for everyone, admins included. + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + assertThat(service.canAuthorCustomApi(user(7))).isFalse(); + } + + @Test + void vendorPresetsAreNotGatedByTheCustomApiFlag() { + // Purview/ConsignO carry a fixed shape: the worst a user can do is misconfigure their own + // connection, so they stay self-serve even with custom authoring switched off. + applicationProperties.getPolicies().setAllowCustomApiIntegrations(false); + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfig created = + service.create(request(IntegrationType.PURVIEW, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.PURVIEW); + } + + @Test + void editingACustomApisConfigNeedsTheSameRightsAsCreatingIt() { + // Otherwise the base URL and body could be rewritten by someone who could never have + // authored them. + IntegrationConfig cfg = config(5L); + cfg.setIntegrationType(IntegrationType.API); + when(repository.findById(5L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(ownership.isAdmin(any())).thenReturn(false); + + assertThatThrownBy( + () -> + service.update( + 5L, + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void customApiAuthoringIsOnByDefaultForAdmins() { + when(ownership.isAdmin(any())).thenReturn(true); + + assertThat(service.canAuthorCustomApi(user(7))).isTrue(); } @Test @@ -112,9 +202,9 @@ class IntegrationConfigServiceTest { User user = user(7); IntegrationConfig created = - service.create(request(IntegrationType.API, OwnerScope.USER, null), user); + service.create(request(IntegrationType.MCP, OwnerScope.USER, null), user); - assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.MCP); assertThat(created.getName()).isEqualTo("name"); verify(ownership) .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 21a9f3e42e..2e9613a024 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -35,6 +35,7 @@ class PolicyValidatorTest { @Mock private PolicyTrigger trigger; @Mock private InputSource inputSource; @Mock private PolicyOutputSink outputSink; + @Mock private PipelineStepValidator stepValidator; private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyValidator validator; @@ -43,7 +44,11 @@ class PolicyValidatorTest { void setUp() { validator = new PolicyValidator( - List.of(trigger), List.of(inputSource), List.of(outputSink), sourceStore); + List.of(trigger), + List.of(inputSource), + List.of(outputSink), + List.of(stepValidator), + sourceStore); } @Test diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 6cf87f4252..e3c636b49e 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4775,13 +4775,13 @@ hint = "Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG fil label = "Upload Image" placeholder = "Select image file" -[imageToPdf] -tags = "conversion,img,jpg,picture,photo" - [imageToPDF] header = "Image to PDF" title = "Image to PDF" +[imageToPdf] +tags = "conversion,img,jpg,picture,photo" + [infoBanner] dismiss = "Dismiss" @@ -6499,33 +6499,297 @@ beta = "Beta" ga = "GA" [portal.connections] -createTitle = "New S3 connection" +createTitle = "New connection" +createTitleFor = "Connect {{name}}" delete = "Delete" edit = "Edit" -editTitle = "Edit S3 connection" -subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to." +editTitle = "Edit connection" +subtitle = "Reusable credentials that sources, pipeline outputs and integration steps connect to." +unsupportedType = "This connection type cannot be edited here." [portal.connections.actions] new = "New connection" +[portal.connections.categories.advanced] +description = "For a service we do not support yet." +label = "Advanced" + +[portal.connections.categories.audit] +description = "Record what happened to a document, where your auditors already look." +label = "Audit & compliance logging" + +[portal.connections.categories.notify] +description = "Tell another system that something happened." +label = "Notify & workflow" + +[portal.connections.categories.security] +description = "Scan or label a document before it moves on." +label = "Security & classification" + +[portal.connections.categories.signing] +description = "Send a document out to be signed or notarized." +label = "E-signature & notarization" + +[portal.connections.categories.storage] +description = "Where processed documents end up - a bucket, a drive, a ticket or a page." +label = "File & attach" + +[portal.connections.commonFields.accessToken] +helperText = "Stirling stores this encrypted and never shows it again." +label = "Access token" + +[portal.connections.commonFields.apiKey] +label = "API key" + +[portal.connections.commonFields.apiToken] +label = "API token" + +[portal.connections.commonFields.baseUrl] +helperText = "Steps using this connection can only call paths under this URL, never another host." +label = "Server URL" + +[portal.connections.commonFields.email] +label = "Email" + +[portal.connections.commonFields.password] +label = "Password" + +[portal.connections.commonFields.username] +label = "Username" + +[portal.connections.commonFields.webhookUrl] +helperText = "Paste the URL the service gave you. Only paths under it can be called." +label = "Webhook URL" + [portal.connections.empty] -description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs." +description = "Add a connection to reuse the same credentials across sources, pipeline outputs and integration steps." title = "No connections yet" +[portal.connections.fields] +name = "Connection name" +namePlaceholder = "e.g. Claims bucket" + [portal.connections.picker] cancel = "Cancel" createNew = "New connection..." placeholder = "Select a connection" save = "Save connection" -[portal.connections.s3.fields] -name = "Connection name" -namePlaceholder = "e.g. Claims bucket" +[portal.connections.picker2] +back = "Change integration" +heading = "What do you want to connect?" +noResultsBody = "We do not support it yet - tell us about it and we will look at adding it." +noResultsTitle = "Nothing matches \"{{query}}\"" +searchPlaceholder = "Search integrations - try \"sign\", \"ocr\" or a product name" [portal.connections.table] -bucket = "Bucket" +detail = "Details" name = "Name" -region = "Region" +type = "Type" + +[portal.connections.types.api] +description = "Call any HTTP API from a policy. You choose the URL, authentication and payload." +label = "Custom API" + +[portal.connections.types.api.fields.authType] +label = "Authentication" + +[portal.connections.types.api.fields.authType.options.basic] +label = "Username and password" + +[portal.connections.types.api.fields.authType.options.bearer] +label = "Bearer token" + +[portal.connections.types.api.fields.authType.options.header] +label = "Token in a custom header" + +[portal.connections.types.api.fields.authType.options.none] +label = "None" + +[portal.connections.types.api.fields.authType.options.tokenLogin] +label = "Log in for a token" + +[portal.connections.types.api.fields.baseUrl] +helperText = "Steps using this connection can only call paths under this URL, never another host." +label = "Base URL" +placeholder = "https://api.example.com/v1" + +[portal.connections.types.api.fields.headerName] +label = "Header name" +placeholder = "X-API-Key" + +[portal.connections.types.api.fields.loginPath] +label = "Login path" +placeholder = "/auth/login" + +[portal.connections.types.api.fields.password] +label = "Password" + +[portal.connections.types.api.fields.resultUrlHosts] +helperText = "If the API replies with a download link, only these hosts may be fetched. Separate with commas. Leave blank to allow only the base URL's own host." +label = "Allowed result hosts" + +[portal.connections.types.api.fields.token] +label = "Token" + +[portal.connections.types.api.fields.tokenHeaderName] +label = "Send the token as this header" + +[portal.connections.types.api.fields.tokenResponseHeader] +helperText = "The response header the login call returns the token in, e.g. X-Auth-Token." +label = "Token response header" + +[portal.connections.types.api.fields.username] +label = "Username" + +[portal.connections.types.clamav] +baseUrlPlaceholder = "http://clamav-rest:9000" +description = "Scan documents for malware on a server you run - nothing leaves your network." +label = "ClamAV (self-hosted)" + +[portal.connections.types.cloudmersive] +description = "Virus and content scanning for documents in a policy." +label = "Cloudmersive" + +[portal.connections.types.cloudmersiveadvanced] +description = "Block documents containing macros, executables, scripts or embedded objects - not just known malware." +label = "Cloudmersive Advanced Scan" + +[portal.connections.types.confluence] +baseUrlPlaceholder = "https://your-site.atlassian.net/wiki/api/v2" +description = "Attach processed documents to a Confluence page." +label = "Confluence" + +[portal.connections.types.consigno] +description = "Send documents to ConsignO Cloud (Notarius) for electronic signature and notarization." +label = "ConsignO Cloud" + +[portal.connections.types.consigno.fields.baseUrl] +helperText = "Your ConsignO Cloud environment, including /api/v1." +label = "Server URL" +placeholder = "https://your-org.consignocloud.com/api/v1" + +[portal.connections.types.consigno.fields.clientId] +label = "API key" + +[portal.connections.types.consigno.fields.clientSecret] +label = "API secret" + +[portal.connections.types.consigno.fields.password] +label = "Password" + +[portal.connections.types.consigno.fields.tenantId] +helperText = "Only needed if your organisation uses tenants." +label = "Tenant" + +[portal.connections.types.consigno.fields.username] +label = "Username" + +[portal.connections.types.discord] +baseUrlPlaceholder = "https://discord.com/api/webhooks/..." +description = "Post a message to Discord when a policy processes a document." +label = "Discord" + +[portal.connections.types.elastic] +baseUrlPlaceholder = "https://your-cluster:9200" +description = "Index an audit event in Elasticsearch each time a policy handles a document." +label = "Elasticsearch" + +[portal.connections.types.googlechat] +baseUrlPlaceholder = "https://chat.googleapis.com/v1/spaces/..." +description = "Post a message to a Google Chat space when a policy runs." +label = "Google Chat" + +[portal.connections.types.jira] +baseUrlPlaceholder = "https://your-site.atlassian.net" +description = "Attach processed documents to a Jira issue." +label = "Jira" + +[portal.connections.types.mailgun] +description = "Email a document or a notification when a policy runs." +label = "Mailgun" + +[portal.connections.types.nextcloud] +baseUrlPlaceholder = "https://your-server/remote.php/dav" +description = "File processed documents into Nextcloud." +label = "Nextcloud" + +[portal.connections.types.nextcloud.fields.appPassword] +label = "App password" + +[portal.connections.types.presidio] +baseUrlPlaceholder = "http://presidio-analyzer:3000" +description = "Detect personal data in document text using a Presidio server you run." +label = "Presidio (self-hosted)" + +[portal.connections.types.purview] +description = "Read and apply Microsoft Purview sensitivity labels on PDFs." +label = "Microsoft Purview" + +[portal.connections.types.purview.fields.clientId] +helperText = "Optional. Only needed to pick labels by name; labelling works without it." +label = "Application (client) ID" + +[portal.connections.types.purview.fields.clientSecret] +label = "Client secret" + +[portal.connections.types.purview.fields.tenantId] +helperText = "Stamped onto every label as its SiteId, so Purview-aware tools know which organisation applied it." +label = "Directory (tenant) ID" +placeholder = "cb46c030-1825-4e81-a295-151c039dbf02" + +[portal.connections.types.s3] +description = "An S3 bucket that sources can read from and pipeline outputs can write to." +label = "S3 storage" + +[portal.connections.types.s3.fields.accessKeyId] +label = "Access key ID" + +[portal.connections.types.s3.fields.bucket] +label = "Bucket" + +[portal.connections.types.s3.fields.endpoint] +helperText = "Leave blank for AWS. Set this for MinIO or another S3-compatible store." +label = "Endpoint" + +[portal.connections.types.s3.fields.region] +label = "Region" + +[portal.connections.types.s3.fields.secretAccessKey] +label = "Secret access key" + +[portal.connections.types.sendgrid] +description = "Email a document or a notification when a policy runs." +label = "SendGrid" + +[portal.connections.types.slack] +description = "Post a message to Slack when a policy processes a document." +label = "Slack" + +[portal.connections.types.splunk] +baseUrlPlaceholder = "https://your-splunk:8088" +description = "Send an audit event to Splunk each time a policy handles a document." +label = "Splunk" + +[portal.connections.types.sumologic] +baseUrlPlaceholder = "https://endpoint.collection.sumologic.com/receiver/v1/http/..." +description = "Send an audit event to a Sumo Logic HTTP collector." +label = "Sumo Logic" + +[portal.connections.types.teams] +baseUrlPlaceholder = "https://your-org.webhook.office.com/webhookb2/..." +description = "Post a message to a Teams channel when a policy runs." +label = "Microsoft Teams" + +[portal.connections.types.webhook] +baseUrlPlaceholder = "https://example.com/hooks/documents" +description = "Post each document, or just the details, to a URL you choose." +label = "Webhook" + +[portal.connections.types.zapier] +baseUrlPlaceholder = "https://hooks.zapier.com/hooks/catch/..." +description = "Trigger a Zap or Make scenario from a policy." +label = "Zapier & Make" [portal.docs] browse = "Browse docs" @@ -7300,6 +7564,8 @@ newPipeline = "New pipeline" [portal.pipelines.builder] addStep = "Add tool" back = "Back to pipelines" +chooseAccount = "Choose an account" +chooseOperation = "Choose what this step does" discard = "Discard changes" enabled = "Enabled" keepEditing = "Keep editing" @@ -7309,6 +7575,8 @@ pipelineSettings = "Pipeline settings" searchTools = "Search tools" selectToolBody = "Add a tool to build your pipeline." selectToolTitle = "No tools yet" +sendToSystem = "Send to another system" +stepsNeedSetup = "These steps still need an operation and an account chosen before saving: {{tools}}." toolSettings = "Tool settings" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" @@ -7474,6 +7742,29 @@ minConfidence = "Min confidence" 2 = "Name" 3 = "Normalize" +[portal.policies.config.purview] +label = "Apply a Microsoft Purview sensitivity label" + +[portal.policies.config.purview.fields] +connection = "Purview tenant" +labelId = "Label ID" +labelIdHelp = "The label's GUID, from the Purview portal. Applying a label writes its metadata onto the PDF; it cannot encrypt, which needs the Microsoft client." +labelName = "Label name (optional)" +labelNameHelp = "Recorded alongside the ID so people and tools can recognise the label." + +[portal.policies.config.purview.fields.method] +help = "Purview distinguishes a label a policy applied from one a person chose." +label = "How the label is recorded" +privileged = "As chosen by a person" +standard = "As applied automatically" + +[portal.policies.config.purviewRead] +label = "Read a Microsoft Purview label" + +[portal.policies.config.purviewRead.fields] +connection = "Purview tenant" +connectionHelp = "Reads the label the document already carries, so later steps can act on it." + [portal.policies.config.retention] summary = "Enforces how long documents are kept, when to archive, and when to delete." @@ -7552,6 +7843,183 @@ description = "Your policies are saved and will appear once the connection is re retry = "Retry" title = "Backend unavailable" +[portal.policies.operations] +change = "Change what this step does" +noResults = "No step matches that. Try a product name, or \"scan\", \"notify\", \"attach\"." +searchPlaceholder = "Search steps - try \"scan\", \"attach\" or a product name" + +[portal.policies.operations.bodyMode] +binary = "Raw file bytes" +json = "Inside a JSON payload" +multipart = "As a file upload (multipart)" + +[portal.policies.operations.clamavScan] +description = "Your own ClamAV server checks the document; nothing leaves your network." +label = "Scan for viruses (self-hosted)" + +[portal.policies.operations.cloudmersiveAdvancedScan] +description = "Rejects documents carrying macros, executables, scripts or embedded objects." +label = "Block active content" + +[portal.policies.operations.cloudmersiveScan] +description = "Cloudmersive checks the document and the run stops if it is not clean." +label = "Scan for viruses" + +[portal.policies.operations.confluenceAttach] +description = "Files the processed document onto a page." +label = "Attach to a Confluence page" + +[portal.policies.operations.consignoSubmit] +description = "Submits the document to ConsignO Cloud for signing." +label = "Send for signature (ConsignO)" +note = "Submits the document. Retrieving the signed copy is not yet supported, because a step cannot pass a result to a later step." + +[portal.policies.operations.customApiCall] +description = "Author the call yourself: path, method, body and response handling." +label = "Call a custom API" + +[portal.policies.operations.discordNotify] +description = "Tells a channel that the policy handled a document." +label = "Post a message to Discord" + +[portal.policies.operations.elasticIndex] +description = "Records what the policy did as a searchable document." +label = "Index an audit event in Elasticsearch" + +[portal.policies.operations.fields.bodyMode] +helperText = "Multipart suits upload APIs; JSON suits APIs that want the file inside a payload; binary sends the raw bytes." +label = "How to send the document" + +[portal.policies.operations.fields.bodyTemplate] +helperText = "A JSON body sent as-is. {{document.base64}} carries the file; {{document.*}} and {{run.*}} are filled in per document." +label = "JSON body" + +[portal.policies.operations.fields.connection] +label = "Account" + +[portal.policies.operations.fields.domain] +label = "Mailgun domain" +placeholder = "mg.acme.com" + +[portal.policies.operations.fields.fileFieldName] +helperText = "The form field the API expects the document under." +label = "File field name" +placeholder = "file" + +[portal.policies.operations.fields.from] +label = "From" +placeholder = "processor@acme.com" + +[portal.policies.operations.fields.headers] +helperText = "A JSON object of header name/value pairs. The connection's credential is added for you." +label = "Headers" + +[portal.policies.operations.fields.index] +label = "Index" +placeholder = "stirling-audit" + +[portal.policies.operations.fields.issueKey] +label = "Issue key" +placeholder = "OPS-42" + +[portal.policies.operations.fields.message] +helperText = "Sent as the message body. {{document.*}} and {{run.*}} are filled in per document." +label = "Message" + +[portal.policies.operations.fields.method] +label = "Method" + +[portal.policies.operations.fields.pageId] +label = "Page ID" +placeholder = "66186" + +[portal.policies.operations.fields.path] +helperText = "Appended to the connection's base URL. It can never reach another host." +label = "Path" +placeholder = "/v1/scan" + +[portal.policies.operations.fields.remotePath] +helperText = "Where in the drive to write it. {{document.filename}} is filled in per document." +label = "Destination path" +placeholder = "Processed/{{document.filename}}" + +[portal.policies.operations.fields.responseMode] +helperText = "Report leaves the document untouched. Replace swaps it for whatever comes back." +label = "What to do with the reply" + +[portal.policies.operations.fields.signerEmail] +label = "Signer's email" +placeholder = "signer@acme.com" + +[portal.policies.operations.fields.subject] +label = "Subject" +placeholder = "Processed: {{document.filename}}" + +[portal.policies.operations.fields.text] +helperText = "Presidio analyses this text. Use {{document.title}} or paste the text you want checked." +label = "Text to check" + +[portal.policies.operations.fields.to] +label = "To" +placeholder = "records@acme.com" + +[portal.policies.operations.fields.username] +label = "Nextcloud username" +placeholder = "svc-stirling" + +[portal.policies.operations.googlechatNotify] +description = "Tells a space that the policy handled a document." +label = "Post a message to Google Chat" + +[portal.policies.operations.jiraAttach] +description = "Files the processed document onto an issue." +label = "Attach to a Jira issue" + +[portal.policies.operations.mailgunEmail] +description = "Sends the processed document as an attachment." +label = "Email the document (Mailgun)" + +[portal.policies.operations.nextcloudUpload] +description = "Writes the processed document into a folder." +label = "Upload to Nextcloud" + +[portal.policies.operations.presidioAnalyze] +description = "Your own Presidio server reports the personal data it finds." +label = "Detect personal data" +note = "Presidio reads text, not files. Give it the text you want checked — the document itself is not uploaded." + +[portal.policies.operations.responseMode] +replace = "Replace the document with the reply" +report = "Keep the document, record the reply" + +[portal.policies.operations.sendgridEmail] +description = "Sends the processed document as an attachment." +label = "Email the document (SendGrid)" + +[portal.policies.operations.slackNotify] +description = "Tells a channel that the policy handled a document." +label = "Post a message to Slack" + +[portal.policies.operations.splunkEvent] +description = "Records what the policy did, where your auditors already look." +label = "Send an audit event to Splunk" + +[portal.policies.operations.sumologicEvent] +description = "Records what the policy did to an HTTP collector." +label = "Send an audit event to Sumo Logic" + +[portal.policies.operations.teamsNotify] +description = "Tells a channel that the policy handled a document." +label = "Post a message to Teams" + +[portal.policies.operations.webhookPost] +description = "Sends the document itself to a URL you choose." +label = "Post the document to a webhook" + +[portal.policies.operations.zapierNotify] +description = "Hands the run's details to your automation." +label = "Trigger a Zap or Make scenario" + [portal.policies.stats] activeFor = "Active" dataProcessed = "Data processed" @@ -8108,22 +8576,10 @@ top = "Top level only" description = "Pull documents from an Amazon S3 or S3-compatible bucket." label = "Amazon S3" -[portal.sources.types.s3.fields.accessKeyId] -label = "Access key ID" - -[portal.sources.types.s3.fields.bucket] -label = "Bucket" -placeholder = "my-company-inbox" - [portal.sources.types.s3.fields.connection] helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it." label = "Connection" -[portal.sources.types.s3.fields.endpoint] -helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO." -label = "Custom endpoint" -placeholder = "https://s3.example.com" - [portal.sources.types.s3.fields.mode] helperText = "Consume removes each object from the bucket once every policy has processed it." label = "Read mode" @@ -8137,13 +8593,6 @@ helperText = "Only objects whose keys start with this prefix are processed." label = "Key prefix" placeholder = "incoming/" -[portal.sources.types.s3.fields.region] -label = "Region" -placeholder = "us-east-1" - -[portal.sources.types.s3.fields.secretAccessKey] -label = "Secret access key" - [portal.sources.types.unknown] label = "Source" @@ -8868,12 +9317,12 @@ label = "Remove XMP Metadata" [sanitize.steps] settings = "Settings" -[sanitizePdf] -tags = "clean,secure,safe,remove-threats" - [sanitizePDF] title = "Sanitize PDF" +[sanitizePdf] +tags = "clean,secure,safe,remove-threats" + [scalePages] 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" @@ -8881,18 +9330,6 @@ 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." -2 = "Tilt (in degrees) needed before we auto-straighten a photo." -3 = "Tolerance:" -4 = "How closely a color must match the page background to count as background. Higher = looser, lower = stricter." -5 = "Minimum Area:" -6 = "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments." -7 = "Minimum Contour Area:" -8 = "Smallest edge/shape we consider when finding photos (filters dust and specks)." -9 = "Border Size:" - [scannerImageSplit] submit = "Extract Image Scans" tags = "separate,auto-detect,scans,multi-photo,organize" @@ -8924,6 +9361,18 @@ whatThisDoes = "What this does" whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping." whenToUse = "When to use" +[ScannerImageSplit.selectText] +1 = "Angle Threshold:" +10 = "Extra padding (in pixels) around each saved photo so edges aren't cut." +2 = "Tilt (in degrees) needed before we auto-straighten a photo." +3 = "Tolerance:" +4 = "How closely a color must match the page background to count as background. Higher = looser, lower = stricter." +5 = "Minimum Area:" +6 = "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments." +7 = "Minimum Contour Area:" +8 = "Smallest edge/shape we consider when finding photos (filters dust and specks)." +9 = "Border Size:" + [search] noResults = "No results found" placeholder = "Enter search term..." diff --git a/frontend/editor/src/core/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts index 79fee63a7c..9e4e57a8a5 100644 --- a/frontend/editor/src/core/i18n/translationAudit.ts +++ b/frontend/editor/src/core/i18n/translationAudit.ts @@ -89,6 +89,10 @@ export const I18N_PROJECTS: TranslationProject[] = [ // components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the // static scan. /^portal\.sources\.types\./, + // The connection catalogue (connectionTypes.ts) mirrors source types: every key is + // t(`${PREFIX}.${id}.label`) / t(`${COMMON}.${field}.label`) with multi-segment const + // prefixes, so the whole family is matched here rather than by the shape heuristic. + /^portal\.connections\.(types|commonFields)\./, // Portal catalogue copy stored as i18n keys in api/.ts constants // (label maps, role/policy/journey catalogues) and rendered via // t(constant), invisible to the static scan. @@ -99,6 +103,11 @@ export const I18N_PROJECTS: TranslationProject[] = [ /^portal\.procurement\.journeySteps\./, /^portal\.users\.roles\./, /^portal\.policies\.(categories|config|endpoints)\./, + // The integration operations catalogue (stepOperations.ts) assembles every key as + // t(`${PREFIX}.${id}.label`) where PREFIX is the multi-segment const + // "portal.policies.operations" - the shape heuristic treats that interpolation as one + // segment, so this whole catalogue-driven family is matched here instead. + /^portal\.policies\.operations\./, // Policy field labels + option display copy are looked up with keys // derived from catalogue data (t(`policies.field.${key}`), // t(`policyOption.${id}`)) in the PolicyFieldRows and setup wizards — diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index ba0e5466f5..ced2c5dcb6 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -73,6 +73,13 @@ html[data-app-theme="light"] { --c-avatar-5: var(--p-amber-500); --c-avatar-6: var(--p-cyan-500); + /* Connection picker: muted categorical marks + brand-red accent. */ + --c-conn-accent: var(--p-art-red-muted); + --c-conn-storage: var(--p-art-blue-muted); + --c-conn-signing: var(--p-art-green-muted); + --c-conn-notify: var(--p-art-amber-muted); + --c-conn-neutral: var(--p-gray-mid); + /* Always-dark marketing/product hero strip (portal). Same in both themes. */ --c-hero-dark: var(--p-zinc-950); --c-hero-dark-cta-text: var(--p-navy-700); diff --git a/frontend/editor/src/core/theme/primitives.css b/frontend/editor/src/core/theme/primitives.css index 22716816a2..6ea844e9ed 100644 --- a/frontend/editor/src/core/theme/primitives.css +++ b/frontend/editor/src/core/theme/primitives.css @@ -147,6 +147,10 @@ --p-c-acacac: #acacac; --p-c-c084fc: #c084fc; --p-art-red-muted: #c56565; + /* Muted category hues for connection-picker marks. */ + --p-art-blue-muted: #4a7ab5; + --p-art-green-muted: #5d8348; + --p-art-amber-muted: #ac7a2c; --p-c-cf222e: #cf222e; --p-c-d0d6dc: #d0d6dc; --p-c-d0d7de: #d0d7de; diff --git a/frontend/editor/src/portal/api/integrations.ts b/frontend/editor/src/portal/api/integrations.ts index 19f88fea29..01eebe96ab 100644 --- a/frontend/editor/src/portal/api/integrations.ts +++ b/frontend/editor/src/portal/api/integrations.ts @@ -1,12 +1,12 @@ /** - * Integrations service layer: stored connections (S3 today; MCP/API later) that - * policy sources and pipeline outputs reference by id instead of embedding + * Integrations service layer: stored connections (S3, Purview, ConsignO, and free-form API) that + * policy sources, pipeline outputs and integration steps reference by id instead of embedding * credentials. Secrets are write-only - reads return them masked, and sending * the mask back on update keeps the stored value. */ import { apiClient } from "@portal/api/http"; -export type IntegrationType = "S3" | "MCP" | "API"; +export type IntegrationType = "S3" | "MCP" | "API" | "PURVIEW" | "CONSIGNO"; export type OwnerScope = "USER" | "TEAM" | "SERVER"; /** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */ @@ -47,6 +47,20 @@ export async function fetchS3Connections(): Promise { ); } +/** + * What this caller may set up. Answered by the server because the custom-API gate is an + * authorization decision, not a presentation one - the create call is refused regardless. + */ +export interface IntegrationCapabilities { + customApi: boolean; +} + +export async function fetchIntegrationCapabilities(): Promise { + return apiClient.local.json( + "/api/v1/integrations/capabilities", + ); +} + export async function createIntegration( body: IntegrationConfigRequest, ): Promise { diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index a26556f5a2..fb3788dc76 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -291,7 +291,14 @@ export const POLICY_CONFIG: Record = { "portal.policies.config.compliance.rules.2", ], scopeLabel: "portal.policies.config.scopeAll", - defaultOperations: [policyStep("sanitize"), policyStep("flatten")], + // Apply writes our sensitivity label into the document after it is sanitised and flattened. + // Offered only once a Purview tenant is connected (it needs a tenant connection and a label + // GUID, which no default can guess), and hidden entirely until then. + defaultOperations: [ + policyStep("sanitize"), + policyStep("flatten"), + policyStep("purviewApplyLabel"), + ], fields: [ { label: "portal.policies.config.compliance.fields.frameworks", diff --git a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx index 672383ecd3..213217de5b 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineStepSettings.tsx @@ -7,6 +7,10 @@ import { type ToolRegistry } from "@app/data/toolsTaxonomy"; import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig"; +import { isIntegrationStep } from "@portal/components/pipelines/integrationStep"; +import type { ExternalApiStepParams } from "@portal/components/policies/stepOperations"; + interface PipelineStepSettingsProps { step: WorkingToolStep; registry: Partial; @@ -23,8 +27,21 @@ export function PipelineStepSettings({ registry, onChange, }: PipelineStepSettingsProps) { + // Hooks first: selecting a different step re-renders this same instance, so an early return + // above useTranslation would change the hook count between renders and crash. const { t } = useTranslation(); + // An integration step is configured by the operations catalogue, not by a tool's settings UI: + // it has no registry entry to look one up from. + if (isIntegrationStep(step)) { + return ( + onChange(params as never)} + /> + ); + } + if (step.support === "noSettings") { return ( void; onClose: () => void; + /** + * Catalogue operations that hand the document to an outside system. Kept apart from the tool + * groups because they are a different species - a tool transforms the document in place, these + * call somebody else - and grouping them under a tool subcategory would bury that. + */ + operations?: StepOperation[]; + onPickOperation?: (operation: StepOperation) => void; } /** * Type-to-filter, category-grouped tool picker for adding a step to a pipeline. Replaces the flat * wall of tool pills so the list stays usable as the tool count grows. */ -export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) { +export function ToolPicker({ + tools, + onPick, + onClose, + operations = [], + onPickOperation, +}: ToolPickerProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); + const matchedOperations = useMemo( + () => + onPickOperation + ? searchOperations(operations, query, (key) => t(key)) + : [], + [operations, onPickOperation, query, t], + ); + const groups = useMemo(() => { const q = query.trim().toLowerCase(); const matched = q @@ -58,7 +83,7 @@ export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) { />

- {groups.length === 0 ? ( + {groups.length === 0 && matchedOperations.length === 0 ? (

{t("portal.pipelines.builder.noToolMatches")}

@@ -93,6 +118,36 @@ export function ToolPicker({ tools, onPick, onClose }: ToolPickerProps) {
)) )} + + {matchedOperations.length > 0 && onPickOperation ? ( +
+
+ {t("portal.pipelines.builder.sendToSystem")} +
+ {matchedOperations.map((op) => ( + + ))} +
+ ) : null}
); diff --git a/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts b/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts new file mode 100644 index 0000000000..e8ea993088 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/integrationStep.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { + INTEGRATION_ENDPOINT, + integrationStepConfigured, + isIntegrationStep, + newIntegrationStep, + stepOperation, +} from "@portal/components/pipelines/integrationStep"; +import { + buildStepParameters, + operationById, +} from "@portal/components/policies/stepOperations"; +import { + serializeToolStep, + type WorkingToolStep, +} from "@app/hooks/tools/shared/toolAutomation"; + +describe("integration steps in a pipeline", () => { + it("creates a step the backend will dispatch generically", () => { + const step = newIntegrationStep(operationById("discordNotify")!); + + expect(step.toolId).toBeNull(); + expect(step.operation).toBe(INTEGRATION_ENDPOINT); + expect(isIntegrationStep(step)).toBe(true); + }); + + it("survives serialisation verbatim, so the saved pipeline keeps its config", () => { + // toolId null takes serializeToolStep's unmapped path; if that ever changed, an integration + // step would be rewritten on save and silently lose its parameters. + const op = operationById("jiraAttach")!; + const step = newIntegrationStep(op); + // Configure it the way the inspector does: rebuild from the catalogue with real answers. + step.params = buildStepParameters(op, "12", { + issueKey: "OPS-42", + }) as never; + + const wire = serializeToolStep(step, {}); + + expect(wire.operation).toBe(INTEGRATION_ENDPOINT); + expect(wire.parameters.connectionId).toBe("12"); + expect(wire.parameters.path).toBe("/rest/api/3/issue/OPS-42/attachments"); + expect(JSON.parse(wire.parameters.headers as string)).toEqual({ + "X-Atlassian-Token": "no-check", + }); + }); + + it("leaves an unfilled field blank rather than shipping the placeholder", () => { + // A freshly added step is deliberately unconfigured. What matters is that {{issueKey}} does + // not survive into the wire call, where Jira would receive it as a literal path segment. + const step = newIntegrationStep(operationById("jiraAttach")!); + expect(step.params.path).not.toContain("{{"); + expect(integrationStepConfigured(step)).toBe(false); + }); + + it("remembers which operation it is, so the builder can name and edit it", () => { + const step = newIntegrationStep(operationById("splunkEvent")!); + expect(stepOperation(step)?.id).toBe("splunkEvent"); + }); + + it("is not configured until an account is chosen", () => { + const step = newIntegrationStep(operationById("clamavScan")!); + // Created deliberately blank so the operator sees it in the chain and fills it in. + expect(integrationStepConfigured(step)).toBe(false); + + (step.params as Record).connectionId = "4"; + expect(integrationStepConfigured(step)).toBe(true); + }); + + it("leaves ordinary tool steps alone", () => { + const toolStep = { + toolId: "compress", + operation: "/api/v1/misc/compress-pdf", + params: {}, + support: "supported", + } as unknown as WorkingToolStep; + expect(isIntegrationStep(toolStep)).toBe(false); + expect(stepOperation(toolStep)).toBeUndefined(); + expect(integrationStepConfigured(toolStep)).toBe(true); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/integrationStep.ts b/frontend/editor/src/portal/components/pipelines/integrationStep.ts new file mode 100644 index 0000000000..93754ccefb --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/integrationStep.ts @@ -0,0 +1,62 @@ +/** + * Integration operations as pipeline steps. + * + * A pipeline step is an endpoint path plus parameters, and the engine dispatches it generically — + * so an integration operation is already a legal step. What it lacked was a way to *pick* one and + * *configure* it in the builder, whose picker is fed by the editor's tool registry and does not + * know about them. + * + * These steps deliberately stay `toolId: null`. They are not registry tools, and pretending + * otherwise would mean inventing a fake tool id that `serializeToolStep` would then try to resolve + * an endpoint from. The unmapped path already round-trips a step verbatim, which is exactly the + * behaviour wanted here; the only thing added is that the builder can now recognise and edit them + * rather than showing them as an opaque "unknown step". + */ + +import type { ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes"; +import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation"; +import { + buildStepParameters, + emptyOperationValues, + operationById, + type StepOperation, +} from "@portal/components/policies/stepOperations"; + +/** The one endpoint every catalogue operation dispatches through. */ +export const INTEGRATION_ENDPOINT = "/api/v1/integration/external-api-call"; + +export function isIntegrationStep(step: WorkingToolStep): boolean { + return step.toolId === null && step.operation === INTEGRATION_ENDPOINT; +} + +/** A new pipeline step for a chosen operation, seeded with the catalogue's defaults. */ +export function newIntegrationStep(op: StepOperation): WorkingToolStep { + const values = emptyOperationValues(op); + return { + toolId: null, + operation: INTEGRATION_ENDPOINT, + // Connection is chosen in the inspector; the step is created unconfigured on purpose so the + // operator sees it in the chain and fills it in, rather than the picker blocking on a modal. + params: buildStepParameters(op, "", values) as unknown as ErasedToolParams, + support: "unknown", + }; +} + +/** + * The operation a step was built from, or undefined if it predates the catalogue (a pipeline + * authored through the API can name the endpoint without an operationId). + */ +export function stepOperation( + step: WorkingToolStep, +): StepOperation | undefined { + if (!isIntegrationStep(step)) return undefined; + const id = (step.params as Record).operationId; + return typeof id === "string" && id ? operationById(id) : undefined; +} + +/** True once the step can actually run: an operation chosen and an account selected. */ +export function integrationStepConfigured(step: WorkingToolStep): boolean { + if (!isIntegrationStep(step)) return true; + const params = step.params as Record; + return Boolean(params.operationId) && Boolean(params.connectionId); +} diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx new file mode 100644 index 0000000000..7f1c232429 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.test.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +import { PolicyExternalApiConfig } from "@portal/components/policies/PolicyExternalApiConfig"; +import { + buildStepParameters, + operationById, + type ExternalApiStepParams, +} from "@portal/components/policies/stepOperations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + createIntegration: vi.fn(), + updateIntegration: vi.fn(), +})); + +vi.mock("@portal/api/http", () => ({ + errorMessage: (e: unknown) => String(e), +})); + +// A stateful host so the controlled component behaves as it does in the builder, and the test can +// read the parameters after each change. +let latest: ExternalApiStepParams; +function Harness({ initial }: { initial: ExternalApiStepParams }) { + const [params, setParams] = useState(initial); + latest = params; + return ( + { + latest = p; + setParams(p); + }} + /> + ); +} + +describe("switching an operation's vendor", () => { + beforeEach(() => { + latest = undefined as unknown as ExternalApiStepParams; + }); + + it("drops the account, so a Slack webhook is never carried into a Jira step", async () => { + const discord = buildStepParameters(operationById("discordNotify")!, "5", { + message: "hi", + }); + render(); + + // Start on the Discord form with its account chosen. + expect(latest.operationId).toBe("discordNotify"); + expect(latest.connectionId).toBe("5"); + + // Change the operation, then pick a different vendor. + fireEvent.click(screen.getByText("portal.policies.operations.change")); + fireEvent.click( + await screen.findByText("portal.policies.operations.jiraAttach.label"), + ); + + await waitFor(() => expect(latest.operationId).toBe("jiraAttach")); + // The Discord account did not ride across to the Jira step. + expect(latest.connectionId).toBe(""); + }); +}); diff --git a/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx new file mode 100644 index 0000000000..9299f26071 --- /dev/null +++ b/frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.tsx @@ -0,0 +1,416 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import SearchRoundedIcon from "@mui/icons-material/SearchRounded"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import { Banner, Button, FormField, Input, Select } from "@app/ui"; +import { fetchIntegrationCapabilities } from "@portal/api/integrations"; +import { ConnectionPicker } from "@portal/components/sources/ConnectionPicker"; +import { + CONNECTION_CATEGORIES, + type ConnectionCategory, +} from "@portal/components/sources/connectionTypes"; +import { + STEP_OPERATIONS, + buildStepParameters, + emptyOperationValues, + operationById, + operationsByCategory, + searchOperations, + type ExternalApiStepParams, + type StepOperation, +} from "@portal/components/policies/stepOperations"; + +/** + * Configures a "send the document to another system" step. + * + * The step's own API takes seventeen parameters — path, body mode, file field name, response mode + * and so on. Asking an operator for those is asking them to have read the vendor's API docs, which + * is the difference between supporting a vendor and merely being able to reach it. So this screen + * asks two questions instead: *what do you want to do*, and *with which account*. The catalogue + * fills in the rest. + * + * The escape hatch stays: choosing Custom API reveals the raw call, because an operator connecting + * something we do not ship a template for still needs a way through. + */ +/** + * Every step parameter is a flat string (the pipeline serialises them as form fields), so the + * operator's answers travel JSON-encoded in `operationValues` and are decoded here. + */ +export type ExternalApiParams = ExternalApiStepParams; + +function decodeValues(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + // A hand-edited or truncated value should not break the form. + return {}; + } +} + +interface PolicyExternalApiConfigProps { + parameters: ExternalApiParams; + onChange: (parameters: ExternalApiParams) => void; +} + +export function PolicyExternalApiConfig({ + parameters, + onChange, +}: PolicyExternalApiConfigProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + // Whether to OFFER the escape hatch. The server refuses it regardless of what the client + // believes, so this is presentation only - the same contract the connections tab uses. + const [allowCustom, setAllowCustom] = useState(true); + + useEffect(() => { + fetchIntegrationCapabilities().then( + (c) => setAllowCustom(c.customApi !== false), + () => undefined, + ); + }, []); + + const selected = parameters.operationId + ? operationById(parameters.operationId) + : undefined; + const values = decodeValues(parameters.operationValues); + + const available = useMemo( + () => STEP_OPERATIONS.filter((op) => allowCustom || !op.custom), + [allowCustom], + ); + const matches = useMemo( + () => searchOperations(available, query, (key) => t(key)), + [available, query, t], + ); + const grouped = useMemo(() => operationsByCategory(matches), [matches]); + const searching = query.trim() !== ""; + + function choose(op: StepOperation) { + // Picking from the grid always starts the operation fresh with no account: a Slack webhook is + // not a valid Jira account, and reaching the grid means the operator is choosing anew. The + // account chosen for a previous operation would otherwise ride along, unlisted by the vendor + // filter yet still saved. + onChange(buildStepParameters(op, "", emptyOperationValues(op))); + } + + function setValue(key: string, value: string) { + if (!selected) return; + const next = { ...values, [key]: value }; + onChange( + buildStepParameters(selected, parameters.connectionId ?? "", next), + ); + } + + function setConnection(id: string) { + if (!selected) { + onChange({ ...parameters, connectionId: id }); + return; + } + onChange(buildStepParameters(selected, id, values)); + } + + // ---- step 1: pick what the step should do --------------------------------------------------- + if (!selected) { + const sections: ConnectionCategory[] = searching + ? [] + : CONNECTION_CATEGORIES.filter((c) => (grouped.get(c)?.length ?? 0) > 0); + + return ( +
+
+ + setQuery(e.target.value)} + placeholder={t("portal.policies.operations.searchPlaceholder")} + aria-label={t("portal.policies.operations.searchPlaceholder")} + /> +
+ + {matches.length === 0 ? ( +

+ {t("portal.policies.operations.noResults")} +

+ ) : searching ? ( + + ) : ( + sections.map((category) => ( +
+

+ {t(`portal.connections.categories.${category}.label`)} +

+ +
+ )) + )} +
+ ); + } + + // ---- step 2: the two questions that remain -------------------------------------------------- + return ( +
+ + +

+ {t(selected.descriptionKey)} +

+ + {selected.noteKey && ( + + )} + + + + + + {(selected.fields ?? []).map((field) => ( + + {field.control === "textarea" ? ( +
{{ label }}{{ value }}{{ pair.label }}{{ pair.value }}
")); + assertTrue(html.contains("item one")); + assertTrue(html.contains("Alice")); + } + + @Test + void rendersMarkupCharactersAsText() { + AiDocument.Section text = section("text"); + text.setBody("a x & y"); + + String html = renderer.render(document("Doc", List.of(text))); + + assertFalse(html.contains("")); + assertTrue(html.contains("<b>")); + } + + @Test + void totalRowRenderedWhenPresent() { + AiDocument.Section items = section("line_items"); + items.setColumns(List.of("Item", "Total")); + items.setRows(List.of(List.of("Widget", "$10"))); + items.setTotalRow(List.of("Total", "$10")); + + assertTrue( + renderer.render(document("Table", List.of(items))) + .contains("