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 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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 09/16] 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 10/16] 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 11/16] 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 12/16] 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 13/16] 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 14/16] 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 15/16] 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 16/16] 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; }