From 5d827df08c19c38177119d7a6e42eb64c224ce5f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:17:22 +0000 Subject: [PATCH 01/15] Add onboarding bypass flag V2 version2 version 2 (#5151) ## Summary - add a shared hook that honors a `bypassOnboarding` query parameter and marks onboarding steps as completed for the session - block onboarding orchestrator and UI elements when the bypass flag is present so tours and popups stay hidden ## Testing - ./gradlew build ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_693059f866a8832891dd97f3d52ca5a0) --- .../core/components/onboarding/Onboarding.tsx | 6 ++ .../orchestrator/useOnboardingOrchestrator.ts | 5 +- .../onboarding/useBypassOnboarding.ts | 70 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 frontend/src/core/components/onboarding/useBypassOnboarding.ts diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index eb752ec6aa..e851ee60bf 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -6,6 +6,7 @@ import { isAuthRoute } from '@app/constants/routes'; import { dispatchTourState } from '@app/constants/events'; import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; +import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; import { @@ -29,6 +30,7 @@ export default function Onboarding() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); + const bypassOnboarding = useBypassOnboarding(); const { state, actions } = useOnboardingOrchestrator(); const serverExperience = useServerExperience(); const onAuthRoute = isAuthRoute(location.pathname); @@ -227,6 +229,10 @@ export default function Onboarding() { return modalSlides.findIndex((step) => step.id === currentStep.id); }, [activeFlow, currentStep]); + if (bypassOnboarding) { + return null; + } + if (onAuthRoute) { return null; } diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 07888b7ce6..45d1ef4546 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -17,6 +17,7 @@ import { migrateFromLegacyPreferences, } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { accountService } from '@app/services/accountService'; +import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite']; const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested'; @@ -142,6 +143,7 @@ export function useOnboardingOrchestrator( const serverExperience = useServerExperience(); const { config, loading: configLoading } = useAppConfig(); const location = useLocation(); + const bypassOnboarding = useBypassOnboarding(); const [runtimeState, setRuntimeState] = useState(() => getInitialRuntimeState(defaultState) @@ -213,7 +215,8 @@ export function useOnboardingOrchestrator( const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route)); const loginEnabled = config?.enableLogin === true; const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); - const shouldBlockOnboarding = isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; + const shouldBlockOnboarding = + bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; const conditionContext = useMemo(() => ({ ...serverExperience, diff --git a/frontend/src/core/components/onboarding/useBypassOnboarding.ts b/frontend/src/core/components/onboarding/useBypassOnboarding.ts new file mode 100644 index 0000000000..8e7d9b14f0 --- /dev/null +++ b/frontend/src/core/components/onboarding/useBypassOnboarding.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; +import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; + +const SESSION_KEY = 'onboarding::bypass-all'; +const PARAM_KEY = 'bypassOnboarding'; + +function isTruthy(value: string | null): boolean { + return value?.toLowerCase() === 'true'; +} + +function readStoredBypass(): boolean { + if (typeof window === 'undefined') return false; + try { + return sessionStorage.getItem(SESSION_KEY) === 'true'; + } catch { + return false; + } +} + +function setStoredBypass(enabled: boolean): void { + if (typeof window === 'undefined') return; + try { + if (enabled) { + sessionStorage.setItem(SESSION_KEY, 'true'); + } else { + sessionStorage.removeItem(SESSION_KEY); + } + } catch { + // Ignore storage errors to avoid blocking the bypass flow + } +} + +/** + * Detects the `bypassOnboarding` query parameter and stores it in session storage + * so that onboarding remains disabled while the app is open. Also marks all steps + * as seen to ensure any dependent UI elements remain hidden. + */ +export function useBypassOnboarding(): boolean { + const location = useLocation(); + const [bypassOnboarding, setBypassOnboarding] = useState(() => readStoredBypass()); + const stepsMarkedRef = useRef(false); + + const shouldBypassFromSearch = useMemo(() => { + try { + const params = new URLSearchParams(location.search); + return isTruthy(params.get(PARAM_KEY)); + } catch { + return false; + } + }, [location.search]); + + useEffect(() => { + const fromStorage = readStoredBypass(); + const nextBypass = shouldBypassFromSearch || fromStorage; + setBypassOnboarding(nextBypass); + if (nextBypass) { + setStoredBypass(true); + } + }, [shouldBypassFromSearch]); + + useEffect(() => { + if (!bypassOnboarding || stepsMarkedRef.current) return; + stepsMarkedRef.current = true; + ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id)); + }, [bypassOnboarding]); + + return bypassOnboarding; +} From f2bffe2dc674fb876a21e6d3c7ac7e0a77272a29 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:39:49 +0000 Subject: [PATCH 02/15] Fix-convert-V2 (#5147) Custom processors can now return consume all inputs flag. This allows to have many inputs to single output consumption Fixed multi call conversion logic --- .../web/ReactRoutingController.java | 3 +- .../configuration/SecurityConfiguration.java | 4 +- frontend/package-lock.json | 57 ++++++--- .../useAdjustContrastOperation.ts | 11 +- .../tools/automate/useAutomateOperation.ts | 5 +- .../tools/convert/useConvertOperation.ts | 25 +++- .../extractPages/useExtractPagesOperation.ts | 9 +- .../useRemoveAnnotationsOperation.ts | 9 +- .../hooks/tools/shared/useToolApiCalls.ts | 19 +-- .../hooks/tools/shared/useToolOperation.ts | 113 +++++++++++------- frontend/src/core/utils/automationExecutor.ts | 4 +- frontend/src/core/utils/convertUtils.ts | 12 ++ frontend/src/core/utils/fileUtils.ts | 23 ++++ 13 files changed, 207 insertions(+), 87 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 424621b8ad..7741220f24 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -75,8 +75,7 @@ public class ReactRoutingController { @GetMapping( "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") - public ResponseEntity forwardRootPaths(HttpServletRequest request) - throws IOException { + public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index a3a5eee4f0..ab1e4934d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -331,7 +331,9 @@ public class SecurityConfiguration { formLogin -> formLogin .loginPage("/login") // Redirect here when unauthenticated - .loginProcessingUrl("/perform_login") // Process form posts here (not /login) + .loginProcessingUrl( + "/perform_login") // Process form posts here (not + // /login) .successHandler( new CustomAuthenticationSuccessHandler( loginAttemptService, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e1f885e65..8d90d96588 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -456,6 +456,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -499,6 +500,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -579,6 +581,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz", "integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "1.5.0", "@embedpdf/models": "1.5.0" @@ -678,6 +681,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz", "integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -694,6 +698,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz", "integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -711,6 +716,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz", "integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -764,6 +770,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz", "integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -798,6 +805,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz", "integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -834,6 +842,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz", "integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -909,6 +918,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz", "integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1064,6 +1074,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1107,6 +1118,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2137,6 +2149,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz", "integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2187,6 +2200,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz", "integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2254,6 +2268,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -3186,6 +3201,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -3304,7 +3320,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -4081,6 +4096,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4409,6 +4425,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4419,6 +4436,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4488,6 +4506,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5201,7 +5220,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.24" } @@ -5211,7 +5229,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/shared": "3.5.24" @@ -5222,7 +5239,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/runtime-core": "3.5.24", @@ -5235,7 +5251,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.24", "@vue/shared": "3.5.24" @@ -5262,6 +5277,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5669,7 +5685,6 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -5946,6 +5961,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6993,7 +7009,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7388,6 +7405,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7558,6 +7576,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7724,8 +7743,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/espree": { "version": "10.4.0", @@ -7790,7 +7808,6 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -8881,6 +8898,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9357,7 +9375,6 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -9678,6 +9695,7 @@ "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.19", "@asamuzakjp/dom-selector": "^6.7.3", @@ -10264,8 +10282,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", @@ -11411,6 +11428,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11690,6 +11708,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -12072,6 +12091,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12081,6 +12101,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13592,7 +13613,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -13801,6 +13821,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14102,6 +14123,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14183,6 +14205,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14387,6 +14410,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14538,6 +14562,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -14551,6 +14576,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15162,8 +15188,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/zod": { "version": "3.25.76", diff --git a/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 176ddd4b5f..6524a2585d 100644 --- a/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; +import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation'; import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters'; import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils'; @@ -46,7 +46,7 @@ async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParamet return out; } -async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise { +async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise { // Limit concurrency to avoid exhausting memory/CPU while still getting speedups // Heuristic: use up to 4 workers on capable machines, otherwise 2-3 let CONCURRENCY_LIMIT = 2; @@ -72,7 +72,12 @@ async function processPdfClientSide(params: AdjustContrastParameters, files: Fil return results; }; - return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params)); + const processedFiles = await mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params)); + + return { + files: processedFiles, + consumedAllInputs: false, + }; } export const adjustContrastOperationConfig = { diff --git a/frontend/src/core/hooks/tools/automate/useAutomateOperation.ts b/frontend/src/core/hooks/tools/automate/useAutomateOperation.ts index 004f289027..f6fbcacda7 100644 --- a/frontend/src/core/hooks/tools/automate/useAutomateOperation.ts +++ b/frontend/src/core/hooks/tools/automate/useAutomateOperation.ts @@ -36,7 +36,10 @@ export function useAutomateOperation() { ); console.log(`✅ Automation completed, returning ${finalResults.length} files`); - return finalResults; + return { + files: finalResults, + consumedAllInputs: false, + }; }, [toolRegistry]); return useToolOperation({ diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index 9134c9db4a..9650ac0e2a 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -3,8 +3,8 @@ import apiClient from '@app/services/apiClient'; import { useTranslation } from 'react-i18next'; import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters'; import { createFileFromApiResponse } from '@app/utils/fileResponseUtils'; -import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation'; -import { getEndpointUrl, isImageFormat, isWebFormat } from '@app/utils/convertUtils'; +import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation'; +import { getEndpointUrl, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils'; // Static function that can be used by both the hook and automation executor export const shouldProcessFilesSeparately = ( @@ -21,6 +21,10 @@ export const shouldProcessFilesSeparately = ( (parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') || // PDF to text-like formats should be one output per input (parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) || + // PDF to office format conversions (each PDF should generate its own office file) + (parameters.fromExtension === 'pdf' && isOfficeFormat(parameters.toExtension)) || + // Office files to PDF conversions (each file should be processed separately via LibreOffice) + (isOfficeFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') || // Web files to PDF conversions (each web file should generate its own PDF) ((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') && parameters.toExtension === 'pdf') || @@ -98,7 +102,7 @@ export const createFileFromResponse = ( export const convertProcessor = async ( parameters: ConvertParameters, selectedFiles: File[] -): Promise => { +): Promise => { const processedFiles: File[] = []; const endpoint = getEndpointUrl(parameters.fromExtension, parameters.toExtension); @@ -107,7 +111,9 @@ export const convertProcessor = async ( } // Convert-specific routing logic: decide batch vs individual processing - if (shouldProcessFilesSeparately(selectedFiles, parameters)) { + const isSeparateProcessing = shouldProcessFilesSeparately(selectedFiles, parameters); + + if (isSeparateProcessing) { // Individual processing for complex cases (PDF→image, smart detection, etc.) for (const file of selectedFiles) { try { @@ -134,7 +140,14 @@ export const convertProcessor = async ( processedFiles.push(convertedFile); } - return processedFiles; + // When batch processing multiple files into one output (e.g., 3 images → 1 PDF), + // mark all inputs as consumed even though there's only 1 output file + const isCombiningMultiple = !isSeparateProcessing && selectedFiles.length > 1; + + return { + files: processedFiles, + consumedAllInputs: isCombiningMultiple, + }; }; // Static configuration object @@ -151,7 +164,7 @@ export const useConvertOperation = () => { const customConvertProcessor = useCallback(async ( parameters: ConvertParameters, selectedFiles: File[] - ): Promise => { + ): Promise => { return convertProcessor(parameters, selectedFiles); }, []); diff --git a/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts index 086fd65cc2..687dde1700 100644 --- a/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts +++ b/frontend/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts @@ -1,6 +1,6 @@ import apiClient from '@app/services/apiClient'; import { useTranslation } from 'react-i18next'; -import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation'; +import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; import { ExtractPagesParameters, defaultParameters } from '@app/hooks/tools/extractPages/useExtractPagesParameters'; import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; @@ -23,7 +23,7 @@ async function resolveSelectionToCsv(expression: string, file: File): Promise => { + customProcessor: async (parameters: ExtractPagesParameters, files: File[]): Promise => { const outputs: File[] = []; for (const file of files) { @@ -43,7 +43,10 @@ export const extractPagesOperationConfig = { outputs.push(outFile); } - return outputs; + return { + files: outputs, + consumedAllInputs: false, + }; }, defaultParameters, } as const; diff --git a/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts b/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts index 4078b2b073..e4b176c8d6 100644 --- a/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts +++ b/frontend/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts @@ -1,10 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { useToolOperation, ToolType } from '@app/hooks/tools/shared/useToolOperation'; +import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation'; import { createStandardErrorHandler } from '@app/utils/toolErrorHandler'; import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters'; import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib'; // Client-side PDF processing using PDF-lib -const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise => { +const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise => { const processedFiles: File[] = []; for (const file of files) { @@ -75,7 +75,10 @@ const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParamete } } - return processedFiles; + return { + files: processedFiles, + consumedAllInputs: false, + }; }; // Static configuration object diff --git a/frontend/src/core/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/core/hooks/tools/shared/useToolApiCalls.ts index a8e6a88a02..1ab8925f14 100644 --- a/frontend/src/core/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/core/hooks/tools/shared/useToolApiCalls.ts @@ -4,6 +4,7 @@ import apiClient from '@app/services/apiClient'; // Our configured instance import { processResponse, ResponseHandler } from '@app/utils/toolResponseProcessor'; import { isEmptyOutput } from '@app/services/errorUtils'; import type { ProcessingProgress } from '@app/hooks/tools/shared/useToolState'; +import type { StirlingFile, FileId } from '@app/types/fileContext'; export interface ApiCallsConfig { endpoint: string | ((params: TParams) => string); @@ -18,14 +19,14 @@ export const useToolApiCalls = () => { const processFiles = useCallback(async ( params: TParams, - validFiles: File[], + validFiles: StirlingFile[], config: ApiCallsConfig, onProgress: (progress: ProcessingProgress) => void, onStatus: (status: string) => void, - markFileError?: (fileId: string) => void, - ): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => { + markFileError?: (fileId: FileId) => void, + ): Promise<{ outputFiles: File[]; successSourceIds: FileId[] }> => { const processedFiles: File[] = []; - const successSourceIds: string[] = []; + const successSourceIds: FileId[] = []; const failedFiles: string[] = []; const total = validFiles.length; @@ -35,7 +36,7 @@ export const useToolApiCalls = () => { for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; - console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId }); + console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: file.fileId }); onProgress({ current: i + 1, total, currentFileName: file.name }); onStatus(`Processing ${file.name} (${i + 1}/${total})`); @@ -47,7 +48,7 @@ export const useToolApiCalls = () => { responseType: 'blob', cancelToken: cancelTokenRef.current?.token, }); - console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status }); + console.debug('[processFiles] Response OK', { name: file.name, status: response.status }); // Forward to shared response processor (uses tool-specific responseHandler if provided) const responseFiles = await processResponse( @@ -63,7 +64,7 @@ export const useToolApiCalls = () => { console.warn('[processFiles] Empty output treated as failure', { name: file.name }); failedFiles.push(file.name); try { - (markFileError as any)?.((file as any).fileId); + markFileError?.(file.fileId); } catch (e) { console.debug('markFileError', e); } @@ -71,7 +72,7 @@ export const useToolApiCalls = () => { } processedFiles.push(...responseFiles); // record source id as successful - successSourceIds.push((file as any).fileId); + successSourceIds.push(file.fileId); console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length }); } catch (error) { @@ -82,7 +83,7 @@ export const useToolApiCalls = () => { failedFiles.push(file.name); // mark errored file so UI can highlight try { - (markFileError as any)?.((file as any).fileId); + markFileError?.(file.fileId); } catch (e) { console.debug('markFileError', e); } diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index e1dccd1e70..4804032d0d 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -8,6 +8,7 @@ import { useToolResources } from '@app/hooks/tools/shared/useToolResources'; import { extractErrorMessage } from '@app/utils/toolErrorHandler'; import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '@app/types/fileContext'; import { FILE_EVENTS } from '@app/services/errorUtils'; +import { getFilenameWithoutExtension } from '@app/utils/fileUtils'; import { ResponseHandler } from '@app/utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions'; import { ToolOperation } from '@app/types/file'; @@ -23,6 +24,20 @@ export enum ToolType { custom, } +/** + * Result from custom processor with optional metadata about input consumption. + */ +export interface CustomProcessorResult { + /** Processed output files */ + files: File[]; + /** + * When true, marks all input files as successfully consumed regardless of output count. + * Use when operation combines N inputs into fewer outputs (e.g., 3 images → 1 PDF). + * When false/undefined, uses filename-based mapping to determine which inputs succeeded. + */ + consumedAllInputs?: boolean; +} + /** * Configuration for tool operations defining processing behavior and API integration. * @@ -98,8 +113,12 @@ export interface CustomToolOperationConfig extends BaseToolOperationCon * Custom processing logic that completely bypasses standard file processing. * This tool handles all API calls, response processing, and file creation. * Use for tools with complex routing logic or non-standard processing requirements. + * + * Returns CustomProcessorResult with: + * - files: Processed output files + * - consumedAllInputs: true if operation combines N inputs → fewer outputs */ - customProcessor: (params: TParams, files: File[]) => Promise; + customProcessor: (params: TParams, files: File[]) => Promise; } export type ToolOperationConfig = SingleFileToolOperationConfig | MultiFileToolOperationConfig | CustomToolOperationConfig; @@ -172,17 +191,17 @@ export const useToolOperation = ( } // Handle zero-byte inputs explicitly: mark as error and continue with others - const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0); + const zeroByteFiles = selectedFiles.filter(file => file.size === 0); if (zeroByteFiles.length > 0) { try { for (const f of zeroByteFiles) { - (fileActions.markFileError as any)((f as any).fileId); + fileActions.markFileError(f.fileId); } } catch (e) { console.log('markFileError', e); } } - const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); + const validFiles: StirlingFile[] = selectedFiles.filter(file => file.size > 0); if (validFiles.length === 0) { actions.setError(t('noValidFiles', 'No valid files to process')); return; @@ -215,7 +234,7 @@ export const useToolOperation = ( try { let processedFiles: File[]; - let successSourceIds: string[] = []; + let successSourceIds: FileId[] = []; // Use original files directly (no PDF metadata injection - history stored in IndexedDB) const filesForAPI = extractFiles(validFiles); @@ -233,14 +252,14 @@ export const useToolOperation = ( console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length }); const result = await processFiles( params, - filesForAPI, + validFiles, apiCallsConfig, actions.setProgress, actions.setStatus, - fileActions.markFileError as any + fileActions.markFileError ); processedFiles = result.outputFiles; - successSourceIds = result.successSourceIds as any; + successSourceIds = result.successSourceIds; console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length }); break; } @@ -268,30 +287,40 @@ export const useToolOperation = ( processedFiles = await extractZipFiles(response.data); } // Assume all inputs succeeded together unless server provided an error earlier - successSourceIds = validFiles.map(f => (f as any).fileId) as any; + successSourceIds = validFiles.map(f => f.fileId); break; } case ToolType.custom: { actions.setStatus('Processing files...'); - processedFiles = await config.customProcessor(params, filesForAPI); - // Try to map outputs back to inputs by filename (before extension) - const inputBaseNames = new Map(); - for (const f of validFiles) { - const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase(); - inputBaseNames.set(base, (f as any).fileId); - } - const mappedSuccess: string[] = []; - for (const out of processedFiles) { - const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase(); - const id = inputBaseNames.get(base); - if (id) mappedSuccess.push(id); - } - // Fallback to naive alignment if names don't match - if (mappedSuccess.length === 0) { - successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any; + const result = await config.customProcessor(params, filesForAPI); + + processedFiles = result.files; + const consumedAllInputs = result.consumedAllInputs || false; + + // If consumedAllInputs flag is set, mark all inputs as successful + // (used for operations that combine N inputs into fewer outputs) + if (consumedAllInputs) { + successSourceIds = validFiles.map(f => f.fileId); } else { - successSourceIds = mappedSuccess as any; + // Try to map outputs back to inputs by filename (before extension) + const inputBaseNames = new Map(); + for (const f of validFiles) { + const base = getFilenameWithoutExtension(f.name || ''); + inputBaseNames.set(base, f.fileId); + } + const mappedSuccess: FileId[] = []; + for (const out of processedFiles) { + const base = getFilenameWithoutExtension(out.name || ''); + const id = inputBaseNames.get(base); + if (id) mappedSuccess.push(id); + } + // Fallback to naive alignment if names don't match + if (mappedSuccess.length === 0) { + successSourceIds = validFiles.slice(0, processedFiles.length).map(f => f.fileId); + } else { + successSourceIds = mappedSuccess; + } } break; } @@ -299,16 +328,16 @@ export const useToolOperation = ( // Normalize error flags across tool types: mark failures, clear successes try { - const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[]; - const okSet = new Set((successSourceIds as unknown as string[]) || []); + const allInputIds = validFiles.map(f => f.fileId); + const okSet = new Set(successSourceIds); // Clear errors on successes for (const okId of okSet) { - try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; } + try { fileActions.clearFileError(okId); } catch (_e) { void _e; } } // Mark errors on inputs that didn't succeed for (const id of allInputIds) { if (!okSet.has(id)) { - try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; } + try { fileActions.markFileError(id); } catch (_e) { void _e; } } } } catch (_e) { void _e; } @@ -316,12 +345,12 @@ export const useToolOperation = ( if (externalErrorFileIds.length > 0) { // If backend told us which sources failed, prefer that mapping successSourceIds = validFiles - .map(f => (f as any).fileId) - .filter(id => !externalErrorFileIds.includes(id)) as any; + .map(f => f.fileId) + .filter(id => !externalErrorFileIds.includes(id)); // Also mark failed IDs immediately try { for (const badId of externalErrorFileIds) { - (fileActions.markFileError as any)(badId); + fileActions.markFileError(badId as FileId); } } catch (_e) { void _e; } } @@ -370,7 +399,7 @@ export const useToolOperation = ( ); // Always create child stubs linking back to the successful source inputs const successInputStubs = successSourceIds - .map((id) => selectors.getStirlingFileStub(id as any)) + .map((id) => selectors.getStirlingFileStub(id)) .filter(Boolean) as StirlingFileStub[]; if (successInputStubs.length !== processedFiles.length) { @@ -396,7 +425,7 @@ export const useToolOperation = ( return createStirlingFile(file, childStub.id); }); // Build consumption arrays aligned to the successful source IDs - const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[]; + const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id)); // Outputs and stubs are already ordered by success sequence console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); @@ -413,25 +442,27 @@ export const useToolOperation = ( } catch (error: any) { // Centralized 422 handler: mark provided IDs in errorFileIds try { - const status = (error?.response?.status as number | undefined); - if (status === 422) { + const status = error?.response?.status; + if (typeof status === 'number' && status === 422) { const payload = error?.response?.data; - let parsed: any = payload; + let parsed: unknown = payload; if (typeof payload === 'string') { try { parsed = JSON.parse(payload); } catch { parsed = payload; } - } else if (payload && typeof (payload as any).text === 'function') { + } else if (payload && typeof (payload as Blob).text === 'function') { // Blob or Response-like object from axios when responseType='blob' const text = await (payload as Blob).text(); try { parsed = JSON.parse(text); } catch { parsed = text; } } - let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined; + let ids: string[] | undefined = Array.isArray((parsed as { errorFileIds?: unknown })?.errorFileIds) + ? (parsed as { errorFileIds: string[] }).errorFileIds + : undefined; if (!ids && typeof parsed === 'string') { const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); if (match && match.length > 0) ids = Array.from(new Set(match)); } if (ids && ids.length > 0) { for (const badId of ids) { - try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; } + try { fileActions.markFileError(badId as FileId); } catch (_e) { void _e; } } actions.setStatus('Process failed due to invalid/corrupted file(s)'); // Avoid duplicating toast messaging here diff --git a/frontend/src/core/utils/automationExecutor.ts b/frontend/src/core/utils/automationExecutor.ts index 6f1234f810..2651784e18 100644 --- a/frontend/src/core/utils/automationExecutor.ts +++ b/frontend/src/core/utils/automationExecutor.ts @@ -158,8 +158,8 @@ export const executeToolOperationWithPrefix = async ( try { // Check if tool uses custom processor (like Convert tool) if (config.customProcessor) { - const resultFiles = await config.customProcessor(parameters, files); - return resultFiles; + const result = await config.customProcessor(parameters, files); + return result.files; } // Execute based on tool type diff --git a/frontend/src/core/utils/convertUtils.ts b/frontend/src/core/utils/convertUtils.ts index ef2836058f..b1e87161ba 100644 --- a/frontend/src/core/utils/convertUtils.ts +++ b/frontend/src/core/utils/convertUtils.ts @@ -60,6 +60,18 @@ export const isWebFormat = (extension: string): boolean => { return ['html', 'zip'].includes(extension.toLowerCase()); }; +/** + * Checks if the given extension is an office format (Word, Excel, PowerPoint, OpenOffice) + * These formats use LibreOffice for conversion and require individual file processing + */ +export const isOfficeFormat = (extension: string): boolean => { + return [ + 'docx', 'doc', 'odt', // Word processors + 'xlsx', 'xls', 'ods', // Spreadsheets + 'pptx', 'ppt', 'odp' // Presentations + ].includes(extension.toLowerCase()); +}; + /** * Gets available target extensions for a given source extension * Extracted from useConvertParameters to be reusable in automation settings diff --git a/frontend/src/core/utils/fileUtils.ts b/frontend/src/core/utils/fileUtils.ts index 0f14714019..4061884b55 100644 --- a/frontend/src/core/utils/fileUtils.ts +++ b/frontend/src/core/utils/fileUtils.ts @@ -52,6 +52,29 @@ export function detectFileExtension(filename: string): string { return extension; } +/** + * Removes the file extension from a filename + * @param filename - The filename to process + * @param options - Options for processing + * @param options.preserveCase - If true, preserves original case. If false (default), converts to lowercase + * @returns Filename without extension + * @example + * getFilenameWithoutExtension('document.pdf') // 'document' + * getFilenameWithoutExtension('my.file.name.txt') // 'my.file.name' + * getFilenameWithoutExtension('REPORT.PDF', { preserveCase: true }) // 'REPORT' + */ +export function getFilenameWithoutExtension( + filename: string, + options: { preserveCase?: boolean } = {} +): string { + if (!filename || typeof filename !== 'string') return ''; + + const { preserveCase = false } = options; + const withoutExtension = filename.replace(/\.[^.]+$/, ''); + + return preserveCase ? withoutExtension : withoutExtension.toLowerCase(); +} + /** * Checks if a file is a PDF based on extension and MIME type * @param file - File or file-like object with name and type properties From e59c717dc097cc1bec816fcff0377faf442547de Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:42:04 +0000 Subject: [PATCH 03/15] Fixes state management loops around getting results V2 (#5153) Makes sure settings step collapses in results step Makes sure result step doesn't always reset even in development for baseTool Make sure result step doesn't reset for convert --- .../tools/shared/createToolFlow.tsx | 1 + .../core/hooks/tools/shared/useBaseTool.ts | 46 +++++++++++++++---- frontend/src/core/tools/Convert.tsx | 42 ++++++++++++++--- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/frontend/src/core/components/tools/shared/createToolFlow.tsx b/frontend/src/core/components/tools/shared/createToolFlow.tsx index 5e42e73ab2..4ddaae67c4 100644 --- a/frontend/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/core/components/tools/shared/createToolFlow.tsx @@ -87,6 +87,7 @@ export function createToolFlow(config: ToolFlowConfig steps.create(stepConfig.title, { isVisible: stepConfig.isVisible, + isCollapsed: stepConfig.isCollapsed, onCollapsedClick: stepConfig.onCollapsedClick, tooltip: stepConfig.tooltip }, stepConfig.content) diff --git a/frontend/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/src/core/hooks/tools/shared/useBaseTool.ts index aad6556eaa..1b8db3bd1e 100644 --- a/frontend/src/core/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/core/hooks/tools/shared/useBaseTool.ts @@ -47,6 +47,10 @@ export function useBaseTool(''); + // Tool-specific hooks const params = useParams(); const operation = useOperation(); @@ -54,19 +58,45 @@ export function useBaseTool= minFiles; + const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; + const settingsCollapsed = !hasFiles || hasResults; + // Reset results when parameters change useEffect(() => { operation.resetResults(); onPreviewFile?.(null); }, [params.parameters]); - // Reset results when selected files change + // When operation completes, flag the next selection change to skip reset + // (consumeFiles auto-selects outputs immediately after processing) useEffect(() => { - if (selectedFiles.length > 0) { - operation.resetResults(); - onPreviewFile?.(null); + if (hasResults) { + skipNextSelectionResetRef.current = true; } - }, [selectedFiles.length]); + }, [hasResults]); + + // Reset results when user manually changes file selection + useEffect(() => { + if (selectedFiles.length === 0) return; + + const currentSelection = selectedFiles.map(f => f.fileId).sort().join(','); + + if (currentSelection === previousSelectionRef.current) return; // No change + + // Skip reset if this is the auto-selection after operation completed + if (skipNextSelectionResetRef.current) { + skipNextSelectionResetRef.current = false; + previousSelectionRef.current = currentSelection; + return; + } + + // User manually selected different files - reset results + previousSelectionRef.current = currentSelection; + operation.resetResults(); + onPreviewFile?.(null); + }, [selectedFiles]); // Reset parameters when transitioning from 0 files to at least 1 file useEffect(() => { @@ -101,6 +131,7 @@ export function useBaseTool { + skipNextSelectionResetRef.current = false; operation.resetResults(); onPreviewFile?.(null); }, [operation, onPreviewFile]); @@ -110,11 +141,6 @@ export function useBaseTool= minFiles; - const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; - const settingsCollapsed = !hasFiles || hasResults; - return { // File management selectedFiles, diff --git a/frontend/src/core/tools/Convert.tsx b/frontend/src/core/tools/Convert.tsx index 51353cad46..82852f36d1 100644 --- a/frontend/src/core/tools/Convert.tsx +++ b/frontend/src/core/tools/Convert.tsx @@ -23,6 +23,10 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName()); + // Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs) + const skipNextSelectionResetRef = useRef(false); + const previousSelectionRef = useRef(''); + const scrollToBottom = () => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ @@ -33,24 +37,49 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }; const hasFiles = selectedFiles.length > 0; - const hasResults = convertOperation.downloadUrl !== null; + const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null; const settingsCollapsed = hasResults; + // When operation completes, flag the next selection change to skip reset useEffect(() => { + if (hasResults) { + skipNextSelectionResetRef.current = true; + } + }, [hasResults]); + + // Reset results when user manually changes file selection + useEffect(() => { + const currentSelection = selectedFiles.map(f => f.fileId).sort().join(','); + + if (currentSelection === previousSelectionRef.current) return; // No change + + // Skip reset if this is the auto-selection after operation completed + // Don't analyze file types - would change parameters and trigger another reset + if (skipNextSelectionResetRef.current) { + skipNextSelectionResetRef.current = false; + previousSelectionRef.current = currentSelection; + return; + } + + // User manually selected different files if (selectedFiles.length > 0) { + previousSelectionRef.current = currentSelection; convertParams.analyzeFileTypes(selectedFiles); + if (hasResults) { + convertOperation.resetResults(); + onPreviewFile?.(null); + } } else { - // Only reset when there are no active files at all - // If there are active files but no selected files, keep current format (user filtered by format) + previousSelectionRef.current = ''; if (activeFiles.length === 0) { convertParams.resetParameters(); } } - }, [selectedFiles, activeFiles, convertParams.analyzeFileTypes, convertParams.resetParameters]); + }, [selectedFiles]); useEffect(() => { - // Only clear results if we're not currently processing and parameters changed - if (!convertOperation.isLoading) { + // Reset when user changes conversion parameters (but not during operation) + if (!convertOperation.isLoading && !skipNextSelectionResetRef.current) { convertOperation.resetResults(); onPreviewFile?.(null); } @@ -87,6 +116,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }; const handleSettingsReset = () => { + skipNextSelectionResetRef.current = false; convertOperation.resetResults(); onPreviewFile?.(null); }; From f8dbf171e190c7d026f0cee7dea9245923f8a7e7 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:02:42 +0000 Subject: [PATCH 04/15] Feature/v2/get all info on pdf (#5105) # Description of Changes - Addition of the get all info on PDF tool --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.toml | 85 ++++++ .../tools/getPdfInfo/GetPdfInfoReportView.tsx | 128 ++++++++ .../tools/getPdfInfo/GetPdfInfoResults.tsx | 79 +++++ .../getPdfInfo/sections/KeyValueSection.tsx | 22 ++ .../getPdfInfo/sections/OtherSection.tsx | 84 ++++++ .../getPdfInfo/sections/PerPageSection.tsx | 122 ++++++++ .../getPdfInfo/sections/SummarySection.tsx | 148 ++++++++++ .../sections/TableOfContentsSection.tsx | 35 +++ .../tools/getPdfInfo/shared/KeyValueList.tsx | 29 ++ .../getPdfInfo/shared/ScrollableCodeBlock.tsx | 47 +++ .../tools/getPdfInfo/shared/SectionBlock.tsx | 22 ++ .../getPdfInfo/shared/accordionStyles.ts | 14 + .../validateSignature/reportView/styles.css | 37 ++- .../core/data/useTranslatedToolRegistry.tsx | 6 +- .../getPdfInfo/useGetPdfInfoOperation.ts | 194 +++++++++++++ .../getPdfInfo/useGetPdfInfoParameters.ts | 19 ++ frontend/src/core/styles/theme.css | 2 + frontend/src/core/tools/GetPdfInfo.tsx | 188 ++++++++++++ frontend/src/core/types/getPdfInfo.ts | 273 ++++++++++++++++++ 19 files changed, 1526 insertions(+), 8 deletions(-) create mode 100644 frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx create mode 100644 frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts create mode 100644 frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts create mode 100644 frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts create mode 100644 frontend/src/core/tools/GetPdfInfo.tsx create mode 100644 frontend/src/core/types/getPdfInfo.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index a425ad3d50..dd30ca4499 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -3036,6 +3036,91 @@ title = "Get Info on PDF" header = "Get Info on PDF" submit = "Get Info" downloadJson = "Download JSON" +processing = "Extracting information..." +results = "Results" +noResults = "Run the tool to generate a report." +downloads = "Downloads" +noneDetected = "None detected" +indexTitle = "Index" + +[getPdfInfo.report] +entryLabel = "Full information summary" +shortTitle = "PDF Information" + +[getPdfInfo.sections] +metadata = "Metadata" +formFields = "Form Fields" +basicInfo = "Basic Info" +documentInfo = "Document Info" +compliance = "Compliance" +encryption = "Encryption" +permissions = "Permissions" +other = "Other" +perPageInfo = "Per Page Info" +tableOfContents = "Table of Contents" + +[getPdfInfo.other] +attachments = "Attachments" +embeddedFiles = "Embedded Files" +javaScript = "JavaScript" +layers = "Layers" +structureTree = "StructureTree" +xmp = "XMPMetadata" + +[getPdfInfo.perPage] +size = "Size" +annotations = "Annotations" +images = "Images" +links = "Links" +fonts = "Fonts" +xobjects = "XObject Counts" +multimedia = "Multimedia" + +[getPdfInfo.summary] +pages = "Pages" +fileSize = "File Size" +pdfVersion = "PDF Version" +language = "Language" +title = "PDF Summary" +author = "Author" +created = "Created" +modified = "Modified" +permsAll = "All Permissions Allowed" +permsRestricted = "{{count}} restrictions" +permsMixed = "Some permissions restricted" +hasCompliance = "Has compliance standards" +noCompliance = "No Compliance Standards" +basic = "Basic Information" +documentInfo = "Document Information" +securityTitle = "Security Status" +technical = "Technical" +overviewTitle = "PDF Overview" + +[getPdfInfo.summary.security] +encrypted = "Encrypted PDF - Password protection present" +unencrypted = "Unencrypted PDF - No password protection" + +[getPdfInfo.summary.tech] +images = "Images" +fonts = "Fonts" +formFields = "Form Fields" +embeddedFiles = "Embedded Files" +javaScript = "JavaScript" +layers = "Layers" +bookmarks = "Bookmarks" +multimedia = "Multimedia" + +[getPdfInfo.summary.overview] +untitled = "an untitled document" +unknown = "Unknown Author" +text = "This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}})." + +[getPdfInfo.error] +partial = "Some files could not be processed." +unexpected = "Unexpected error during extraction." + +[getPdfInfo.status] +complete = "Extraction complete" [extractPage] tags = "extract" diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx new file mode 100644 index 0000000000..ec5d3805e3 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { Badge, Divider, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { + PdfInfoReportData, + PdfInfoReportEntry, + PdfInfoBackendData, + ParsedPdfSections, +} from '@app/types/getPdfInfo'; +import '@app/components/tools/validateSignature/reportView/styles.css'; +import SummarySection from '@app/components/tools/getPdfInfo/sections/SummarySection'; +import KeyValueSection from '@app/components/tools/getPdfInfo/sections/KeyValueSection'; +import TableOfContentsSection from '@app/components/tools/getPdfInfo/sections/TableOfContentsSection'; +import OtherSection from '@app/components/tools/getPdfInfo/sections/OtherSection'; +import PerPageSection from '@app/components/tools/getPdfInfo/sections/PerPageSection'; + + +/** Valid section anchor IDs for navigation */ +const VALID_ANCHORS = new Set([ + 'summary', 'metadata', 'formFields', 'basicInfo', 'documentInfo', + 'compliance', 'encryption', 'permissions', 'toc', 'other', 'perPage', +]); + +interface GetPdfInfoReportViewProps { + data: PdfInfoReportData & { scrollTo?: string | null }; +} + +const GetPdfInfoReportView: React.FC = ({ data }) => { + const { t } = useTranslation(); + const containerRef = useRef(null); + const entry: PdfInfoReportEntry | null = data.entries[0] ?? null; + + useEffect(() => { + if (!data.scrollTo || !VALID_ANCHORS.has(data.scrollTo)) return; + const anchor = data.scrollTo; + const container = containerRef.current; + const el = container?.querySelector(`#${anchor}`); + if (el && container) { + // Calculate scroll position with 4rem buffer from top + const bufferPx = parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; + const elementTop = el.getBoundingClientRect().top; + const containerTop = container.getBoundingClientRect().top; + const currentScroll = container.scrollTop; + const targetScroll = currentScroll + (elementTop - containerTop) - bufferPx; + + container.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' }); + + // Flash highlight the section + el.classList.remove('section-flash-highlight'); + void el.offsetWidth; // Force reflow + el.classList.add('section-flash-highlight'); + setTimeout(() => el.classList.remove('section-flash-highlight'), 1500); + } + }, [data.scrollTo]); + + const sections = useMemo((): ParsedPdfSections => { + const raw: PdfInfoBackendData = entry?.data ?? {}; + return { + metadata: raw.Metadata ?? null, + formFields: raw.FormFields ?? raw['Form Fields'] ?? null, + basicInfo: raw.BasicInfo ?? raw['Basic Info'] ?? null, + documentInfo: raw.DocumentInfo ?? raw['Document Info'] ?? null, + compliance: raw.Compliancy ?? raw.Compliance ?? null, + encryption: raw.Encryption ?? null, + permissions: raw.Permissions ?? null, + toc: raw['Bookmarks/Outline/TOC'] ?? raw['Table of Contents'] ?? null, + other: raw.Other ?? null, + perPage: raw.PerPageInfo ?? raw['Per Page Info'] ?? null, + summaryData: raw.SummaryData ?? null, + }; + }, [entry]); + + if (!entry) { + return ( +
+ + No Data + Run the tool to generate the report. + +
+ ); + } + + return ( +
+ + +
+ + + + {entry.fileName} + - {t('getPdfInfo.summary.title', 'PDF Summary')} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +}; + +export default GetPdfInfoReportView; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx new file mode 100644 index 0000000000..5ee89db4aa --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx @@ -0,0 +1,79 @@ +import { useCallback, useMemo } from 'react'; +import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation'; + +interface GetPdfInfoResultsProps { + operation: GetPdfInfoOperationHook; + isLoading: boolean; + errorMessage: string | null; +} + +const findFileByExtension = (files: File[], extension: string) => { + return files.find((file) => file.name.toLowerCase().endsWith(extension)); +}; + +const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoResultsProps) => { + const { t } = useTranslation(); + + const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]); + const selectedFile = useMemo(() => jsonFile ?? null, [jsonFile]); + const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]); + + const handleDownload = useCallback((file: File) => { + const blobUrl = URL.createObjectURL(file); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); + }, []); + + if (isLoading && operation.results.length === 0) { + return ( + + + {t('getPdfInfo.processing', 'Extracting information...')} + + ); + } + + if (!isLoading && operation.results.length === 0) { + return ( + + {t('getPdfInfo.noResults', 'Run the tool to generate a report.')} + + ); + } + + return ( + + {/* No background post-processing once JSON is ready */} + {errorMessage && ( + + {errorMessage} + + )} + + + + {t('getPdfInfo.downloads', 'Downloads')} + + + + + ); +}; + +export default GetPdfInfoResults; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx new file mode 100644 index 0000000000..a98a475688 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/sections/KeyValueSection.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock'; +import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList'; + +interface KeyValueSectionProps { + title: string; + anchorId: string; + obj?: Record | null; + emptyLabel?: string; +} + +const KeyValueSection: React.FC = ({ title, anchorId, obj, emptyLabel }) => { + return ( + + + + ); +}; + +export default KeyValueSection; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx new file mode 100644 index 0000000000..e7eb8d8b3e --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/sections/OtherSection.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Accordion, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { PdfOtherInfo } from '@app/types/getPdfInfo'; +import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock'; +import ScrollableCodeBlock from '@app/components/tools/getPdfInfo/shared/ScrollableCodeBlock'; +import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles'; + +interface OtherSectionProps { + anchorId: string; + other?: PdfOtherInfo | null; +} + +const renderList = (arr: unknown[] | undefined, emptyText: string) => { + if (!arr || arr.length === 0) return {emptyText}; + return ( + + {arr.map((item, idx) => ( + + {typeof item === 'string' ? item : JSON.stringify(item)} + + ))} + + ); +}; + +const OtherSection: React.FC = ({ anchorId, other }) => { + const { t } = useTranslation(); + const noneDetected = t('getPdfInfo.noneDetected', 'None detected'); + + const structureTreeContent = Array.isArray(other?.StructureTree) && other.StructureTree.length > 0 + ? JSON.stringify(other.StructureTree, null, 2) + : null; + + return ( + + + + {t('getPdfInfo.other.attachments', 'Attachments')} + {renderList(other?.Attachments, noneDetected)} + + + {t('getPdfInfo.other.embeddedFiles', 'Embedded Files')} + {renderList(other?.EmbeddedFiles, noneDetected)} + + + {t('getPdfInfo.other.javaScript', 'JavaScript')} + {renderList(other?.JavaScript, noneDetected)} + + + {t('getPdfInfo.other.layers', 'Layers')} + {renderList(other?.Layers, noneDetected)} + + + + + {t('getPdfInfo.other.structureTree', 'StructureTree')} + + + + + + + + {t('getPdfInfo.other.xmp', 'XMPMetadata')} + + + + + + + + + ); +}; + +export default OtherSection; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx new file mode 100644 index 0000000000..4fd257cce6 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/sections/PerPageSection.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { Accordion, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { PdfPerPageInfo, PdfPageInfo, PdfFontInfo } from '@app/types/getPdfInfo'; +import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock'; +import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList'; +import { pdfInfoAccordionStyles } from '@app/components/tools/getPdfInfo/shared/accordionStyles'; + +interface PerPageSectionProps { + anchorId: string; + perPage?: PdfPerPageInfo | null; +} + +const renderList = (arr: unknown[] | undefined, emptyText: string) => { + if (!arr || arr.length === 0) return {emptyText}; + return ( + + {arr.map((item, idx) => ( + + {typeof item === 'string' ? item : JSON.stringify(item)} + + ))} + + ); +}; + +const renderFontsList = (fonts: PdfFontInfo[] | undefined, emptyText: string) => { + if (!fonts || fonts.length === 0) return {emptyText}; + return ( + + {fonts.map((font, idx) => ( + + {`${font.Name ?? 'Unknown'}${font.IsEmbedded ? ' (embedded)' : ''}`} + + ))} + + ); +}; + +const PerPageSection: React.FC = ({ anchorId, perPage }) => { + const { t } = useTranslation(); + const noneDetected = t('getPdfInfo.noneDetected', 'None detected'); + + const hasPages = perPage && Object.keys(perPage).length > 0; + + return ( + + {hasPages ? ( + + {Object.entries(perPage).map(([pageLabel, pageInfo]: [string, PdfPageInfo]) => ( + + + {pageLabel} + + +
+ + {pageInfo?.Size && ( + + {t('getPdfInfo.perPage.size', 'Size')} + + + )} + + {pageInfo?.Annotations && ( + + {t('getPdfInfo.perPage.annotations', 'Annotations')} + + + )} + + {t('getPdfInfo.perPage.images', 'Images')} + {renderList(pageInfo?.Images, noneDetected)} + + + {t('getPdfInfo.perPage.links', 'Links')} + {renderList(pageInfo?.Links, noneDetected)} + + + {t('getPdfInfo.perPage.fonts', 'Fonts')} + {renderFontsList(pageInfo?.Fonts, noneDetected)} + + {pageInfo?.XObjectCounts && ( + + {t('getPdfInfo.perPage.xobjects', 'XObject Counts')} + + + )} + + {t('getPdfInfo.perPage.multimedia', 'Multimedia')} + {renderList(pageInfo?.Multimedia, noneDetected)} + + +
+
+
+ ))} +
+ ) : ( + {noneDetected} + )} +
+ ); +}; + +export default PerPageSection; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx new file mode 100644 index 0000000000..680f4b64dd --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/sections/SummarySection.tsx @@ -0,0 +1,148 @@ +import React, { useMemo } from 'react'; +import { Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { ParsedPdfSections, PdfFontInfo } from '@app/types/getPdfInfo'; +import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock'; +import KeyValueList from '@app/components/tools/getPdfInfo/shared/KeyValueList'; + +interface SummarySectionProps { + sections: ParsedPdfSections; + hideSectionTitle?: boolean; +} + +const SummarySection: React.FC = ({ sections, hideSectionTitle = false }) => { + const { t } = useTranslation(); + + const summaryBlocks = useMemo(() => { + const basic = sections.basicInfo ?? {}; + const docInfo = sections.documentInfo ?? {}; + const metadata = sections.metadata ?? {}; + const encryption = sections.encryption ?? {}; + const permissions = sections.permissions ?? {}; + const summary = sections.summaryData ?? {}; + const other = sections.other ?? {}; + const perPage = sections.perPage ?? {}; + + const pages = basic['Number of pages']; + const fileSizeBytes = basic.FileSizeInBytes; + const pdfVersion = docInfo['PDF version']; + const language = basic.Language; + + const basicInformation: Record = { + [t('getPdfInfo.summary.pages', 'Pages')]: pages, + [t('getPdfInfo.summary.fileSize', 'File Size')]: typeof fileSizeBytes === 'number' ? `${(fileSizeBytes / 1024).toFixed(2)} KB` : fileSizeBytes, + [t('getPdfInfo.summary.pdfVersion', 'PDF Version')]: pdfVersion, + [t('getPdfInfo.summary.language', 'Language')]: language, + }; + + const documentInformation: Record = { + [t('getPdfInfo.summary.title', 'Title')]: metadata.Title, + [t('getPdfInfo.summary.author', 'Author')]: metadata.Author, + [t('getPdfInfo.summary.created', 'Created')]: metadata.CreationDate, + [t('getPdfInfo.summary.modified', 'Modified')]: metadata.ModificationDate, + }; + + const securityStatusText = encryption.IsEncrypted + ? t('getPdfInfo.summary.security.encrypted', 'Encrypted PDF - Password protection present') + : t('getPdfInfo.summary.security.unencrypted', 'Unencrypted PDF - No password protection'); + + const restrictedCount = summary.restrictedPermissionsCount ?? 0; + const permissionsAllAllowed = Object.values(permissions).every((v) => v === 'Allowed'); + const permSummary = permissionsAllAllowed + ? t('getPdfInfo.summary.permsAll', 'All Permissions Allowed') + : restrictedCount > 0 + ? t('getPdfInfo.summary.permsRestricted', '{{count}} restrictions', { count: restrictedCount }) + : t('getPdfInfo.summary.permsMixed', 'Some permissions restricted'); + + const complianceText = sections.compliance && Object.values(sections.compliance).some(Boolean) + ? t('getPdfInfo.summary.hasCompliance', 'Has compliance standards') + : t('getPdfInfo.summary.noCompliance', 'No Compliance Standards'); + + // Helper to get first page data + const firstPage = perPage['Page 1']; + const firstPageFonts: PdfFontInfo[] = firstPage?.Fonts ?? []; + + const technical: Record = { + [t('getPdfInfo.summary.tech.images', 'Images')]: (() => { + const total = basic.TotalImages; + if (typeof total === 'number') return total === 0 ? 'None' : `${total}`; + return 'None'; + })(), + [t('getPdfInfo.summary.tech.fonts', 'Fonts')]: (() => { + if (firstPageFonts.length === 0) return 'None'; + const embedded = firstPageFonts.filter((f) => f.IsEmbedded).length; + return `${firstPageFonts.length} (${embedded} embedded)`; + })(), + [t('getPdfInfo.summary.tech.formFields', 'Form Fields')]: sections.formFields && Object.keys(sections.formFields).length > 0 ? Object.keys(sections.formFields).length : 'None', + [t('getPdfInfo.summary.tech.embeddedFiles', 'Embedded Files')]: other.EmbeddedFiles?.length ?? 'None', + [t('getPdfInfo.summary.tech.javaScript', 'JavaScript')]: other.JavaScript?.length ?? 'None', + [t('getPdfInfo.summary.tech.layers', 'Layers')]: other.Layers?.length ?? 'None', + [t('getPdfInfo.summary.tech.bookmarks', 'Bookmarks')]: sections.toc?.length ?? 'None', + [t('getPdfInfo.summary.tech.multimedia', 'Multimedia')]: firstPage?.Multimedia?.length ?? 'None', + }; + + const overview = (() => { + const tTitle = metadata.Title ? `"${metadata.Title}"` : t('getPdfInfo.summary.overview.untitled', 'an untitled document'); + const author = metadata.Author || t('getPdfInfo.summary.overview.unknown', 'Unknown Author'); + const pagesCount = typeof pages === 'number' ? pages : '?'; + const version = pdfVersion ?? '?'; + return t('getPdfInfo.summary.overview.text', 'This is a {{pages}}-page PDF titled {{title}} created by {{author}} (PDF version {{version}}).', { + pages: pagesCount, + title: tTitle, + author, + version, + }); + })(); + + return { + basicInformation, + documentInformation, + securityStatusText, + permSummary, + complianceText, + technical, + overview, + }; + }, [sections, t]); + + const content = ( + + + {t('getPdfInfo.summary.basic', 'Basic Information')} + + + + {t('getPdfInfo.summary.documentInfo', 'Document Information')} + + + + {t('getPdfInfo.summary.securityTitle', 'Security Status')} + {summaryBlocks.securityStatusText} + {summaryBlocks.permSummary} + {summaryBlocks.complianceText} + + + {t('getPdfInfo.summary.technical', 'Technical')} + + + + {t('getPdfInfo.summary.overviewTitle', 'PDF Overview')} + {summaryBlocks.overview} + + + ); + + if (hideSectionTitle) { + return
{content}
; + } + + return ( + + {content} + + ); +}; + +export default SummarySection; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx b/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx new file mode 100644 index 0000000000..57e4ac2fed --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import type { PdfTocEntry } from '@app/types/getPdfInfo'; +import SectionBlock from '@app/components/tools/getPdfInfo/shared/SectionBlock'; + +interface TableOfContentsSectionProps { + anchorId: string; + tocArray: PdfTocEntry[]; +} + +const TableOfContentsSection: React.FC = ({ anchorId, tocArray }) => { + const { t } = useTranslation(); + const noneDetected = t('getPdfInfo.noneDetected', 'None detected'); + + return ( + + {!tocArray || tocArray.length === 0 ? ( + {noneDetected} + ) : ( + + {tocArray.map((item, idx) => ( + + {typeof item === 'string' ? item : JSON.stringify(item)} + + ))} + + )} + + ); +}; + +export default TableOfContentsSection; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx new file mode 100644 index 0000000000..e0cd809cc4 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/shared/KeyValueList.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Group, Stack, Text } from '@mantine/core'; + +interface KeyValueListProps { + obj?: Record | null; + emptyLabel?: string; +} + +const KeyValueList: React.FC = ({ obj, emptyLabel }) => { + if (!obj || Object.keys(obj).length === 0) { + return {emptyLabel ?? 'None detected'}; + } + return ( + + {Object.entries(obj).map(([k, v]) => ( + + {k} + + {v == null ? '' : String(v)} + + + ))} + + ); +}; + +export default KeyValueList; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx new file mode 100644 index 0000000000..bf04264268 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Code, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface ScrollableCodeBlockProps { + content: string | null | undefined; + maxHeight?: string; + emptyMessage?: string; +} + +/** + * A reusable scrollable code block component with consistent styling. + * Used for displaying large text content like XMP metadata or structure trees. + */ +const ScrollableCodeBlock: React.FC = ({ + content, + maxHeight = '400px', + emptyMessage, +}) => { + const { t } = useTranslation(); + + if (!content) { + return ( + + {emptyMessage ?? t('getPdfInfo.noneDetected', 'None detected')} + + ); + } + + return ( + + {content} + + ); +}; + +export default ScrollableCodeBlock; + diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx b/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx new file mode 100644 index 0000000000..0faa993f60 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/shared/SectionBlock.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Stack, Text, Divider } from '@mantine/core'; + +interface SectionBlockProps { + title: string; + anchorId: string; + children: React.ReactNode; +} + +const SectionBlock: React.FC = ({ title, anchorId, children }) => { + return ( + + {title} + + {children} + + ); +}; + +export default SectionBlock; + + diff --git a/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts b/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts new file mode 100644 index 0000000000..e974c98450 --- /dev/null +++ b/frontend/src/core/components/tools/getPdfInfo/shared/accordionStyles.ts @@ -0,0 +1,14 @@ +import type { AccordionStylesNames } from '@mantine/core'; +import type { CSSProperties } from 'react'; + +type AccordionStyles = Partial>; + +export const pdfInfoAccordionStyles: AccordionStyles = { + item: { + backgroundColor: 'var(--accordion-item-bg)', + }, + control: { + backgroundColor: 'transparent', + }, +}; + diff --git a/frontend/src/core/components/tools/validateSignature/reportView/styles.css b/frontend/src/core/components/tools/validateSignature/reportView/styles.css index b2d01f2242..27b1081d4c 100644 --- a/frontend/src/core/components/tools/validateSignature/reportView/styles.css +++ b/frontend/src/core/components/tools/validateSignature/reportView/styles.css @@ -44,15 +44,15 @@ .simulated-page { width: min(820px, 100%); min-height: 1040px; - background-color: rgb(var(--pdf-light-simulated-page-bg)) !important; - box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important; + background-color: var(--bg-raised) !important; + box-shadow: 0 12px 32px var(--shadow-color) !important; border-radius: 12px !important; padding: 48px 56px !important; position: relative; overflow: hidden; display: flex; flex-direction: column; - color: rgb(var(--pdf-light-simulated-page-text)) !important; + color: var(--text-primary) !important; } /* Container for the interactive report view */ @@ -67,12 +67,12 @@ /* Keep field blocks stable colors across themes */ .field-value { - border: 1px solid rgb(var(--pdf-light-box-border)) !important; - background-color: rgb(var(--pdf-light-box-bg)) !important; + border: 1px solid var(--border-default) !important; + background-color: var(--bg-raised) !important; } .field-container { - color: rgb(var(--pdf-light-simulated-page-text)) !important; + color: var(--text-primary) !important; } /* Thumbnail preview styles */ @@ -103,3 +103,28 @@ color: rgb(var(--pdf-light-text-muted)); background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%); } + +/* Flash highlight animation for section navigation */ +@keyframes section-flash { + 0% { + background-color: rgba(255, 235, 59, 0); + box-shadow: none; + } + 20% { + background-color: rgba(255, 235, 59, 0.35); + box-shadow: 0 0 20px rgba(255, 235, 59, 0.5); + } + 50% { + background-color: rgba(255, 235, 59, 0.25); + box-shadow: 0 0 15px rgba(255, 235, 59, 0.4); + } + 100% { + background-color: rgba(255, 235, 59, 0); + box-shadow: none; + } +} + +.section-flash-highlight { + animation: section-flash 1.5s ease-out; + border-radius: 8px; +} diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 5d0bcd622e..bc514d879a 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -27,6 +27,7 @@ import AdjustContrastSingleStepSettings from "@app/components/tools/adjustContra import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation"; import { getSynonyms } from "@app/utils/toolSynonyms"; import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry"; +import GetPdfInfo from "@app/tools/GetPdfInfo"; import AddWatermark from "@app/tools/AddWatermark"; import AddStamp from "@app/tools/AddStamp"; import AddAttachments from "@app/tools/AddAttachments"; @@ -324,14 +325,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { getPdfInfo: { icon: , name: t("home.getPdfInfo.title", "Get ALL Info on PDF"), - component: null, + component: GetPdfInfo, description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.VERIFICATION, endpoints: ["get-info-on-pdf"], synonyms: getSynonyms(t, "getPdfInfo"), supportsAutomate: false, - automationSettings: null + automationSettings: null, + maxFiles: 1, }, validateSignature: { icon: , diff --git a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts new file mode 100644 index 0000000000..019968bcac --- /dev/null +++ b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import apiClient from '@app/services/apiClient'; +import { useFileContext } from '@app/contexts/file/fileHooks'; +import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import type { StirlingFile } from '@app/types/fileContext'; +import { extractErrorMessage } from '@app/utils/toolErrorHandler'; +import { + PdfInfoReportEntry, + INFO_JSON_FILENAME, +} from '@app/types/getPdfInfo'; +import type { GetPdfInfoParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters'; + +export interface GetPdfInfoOperationHook extends ToolOperationHook { + results: PdfInfoReportEntry[]; +} + +export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => { + const { t } = useTranslation(); + const { selectors } = useFileContext(); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [results, setResults] = useState([]); + + const cancelRequested = useRef(false); + const previousUrl = useRef(null); + + const cleanupDownloadUrl = useCallback(() => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + previousUrl.current = null; + } + }, []); + + const resetResults = useCallback(() => { + cancelRequested.current = false; + setResults([]); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + }, [cleanupDownloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + const executeOperation = useCallback( + async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => { + if (selectedFiles.length === 0) { + setErrorMessage(t('noFileSelected', 'No files selected')); + return; + } + + cancelRequested.current = false; + setIsLoading(true); + setStatus(t('getPdfInfo.processing', 'Extracting information...')); + setErrorMessage(null); + setResults([]); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + + try { + const aggregated: PdfInfoReportEntry[] = []; + const generatedAt = Date.now(); + + for (const file of selectedFiles) { + if (cancelRequested.current) break; + + const formData = new FormData(); + formData.append('fileInput', file); + + try { + const response = await apiClient.post('/api/v1/security/get-info-on-pdf', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + const stub = selectors.getStirlingFileStub(file.fileId); + const entry: PdfInfoReportEntry = { + fileId: file.fileId, + fileName: file.name, + fileSize: file.size ?? null, + lastModified: file.lastModified ?? null, + thumbnailUrl: stub?.thumbnailUrl ?? null, + data: response.data ?? {}, + error: null, + summaryGeneratedAt: generatedAt, + }; + aggregated.push(entry); + } catch (error) { + const stub = selectors.getStirlingFileStub(file.fileId); + aggregated.push({ + fileId: file.fileId, + fileName: file.name, + fileSize: file.size ?? null, + lastModified: file.lastModified ?? null, + thumbnailUrl: stub?.thumbnailUrl ?? null, + data: {}, + error: extractErrorMessage(error), + summaryGeneratedAt: generatedAt, + }); + } + } + + if (!cancelRequested.current) { + setResults(aggregated); + if (aggregated.length > 0) { + // Build V1-compatible JSON: use backend payloads directly. + const payloads = aggregated + .filter((e) => !e.error) + .map((e) => e.data); + const content = payloads.length === 1 ? payloads[0] : payloads; + const json = JSON.stringify(content, null, 2); + const resultFile = new File([json], INFO_JSON_FILENAME, { type: 'application/json' }); + setFiles([resultFile]); + } + + const anyError = aggregated.some((item) => item.error); + if (anyError) { + setErrorMessage(t('getPdfInfo.error.partial', 'Some files could not be processed.')); + } + setStatus(t('getPdfInfo.status.complete', 'Extraction complete')); + } + } catch (e) { + console.error('[getPdfInfo] unexpected failure', e); + setErrorMessage(t('getPdfInfo.error.unexpected', 'Unexpected error during extraction.')); + } finally { + setIsLoading(false); + } + }, + [cleanupDownloadUrl, selectors, t] + ); + + const cancelOperation = useCallback(() => { + if (isLoading) { + cancelRequested.current = true; + setIsLoading(false); + setStatus(t('operationCancelled', 'Operation cancelled')); + } + }, [isLoading, t]); + + const undoOperation = useCallback(async () => { + resetResults(); + }, [resetResults]); + + useEffect(() => { + return () => { + cleanupDownloadUrl(); + }; + }, [cleanupDownloadUrl]); + + return useMemo( + () => ({ + files, + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl, + downloadFilename, + isLoading, + status, + errorMessage, + progress: null, + executeOperation, + resetResults, + clearError, + cancelOperation, + undoOperation, + results, + }), + [ + cancelOperation, + clearError, + downloadFilename, + downloadUrl, + errorMessage, + executeOperation, + files, + isLoading, + resetResults, + results, + status, + ] + ); +}; + + diff --git a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts new file mode 100644 index 0000000000..488484809a --- /dev/null +++ b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoParameters.ts @@ -0,0 +1,19 @@ +import { BaseParameters } from '@app/types/parameters'; +import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; + +export interface GetPdfInfoParameters extends BaseParameters { + // No parameters needed +} + +export const defaultParameters: GetPdfInfoParameters = {}; + +export type GetPdfInfoParametersHook = BaseParametersHook; + +export const useGetPdfInfoParameters = (): GetPdfInfoParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'get-info-on-pdf', + }); +}; + + diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 30991cf30a..8551735eda 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -256,6 +256,7 @@ --header-selected-bg: #1E88E5; /* light mode selected header matches dark */ --header-selected-fg: #FFFFFF; --file-card-bg: #FFFFFF; /* file card background (light/dark paired) */ + --accordion-item-bg: #E8EAED; /* accordion item background - more distinguishable */ /* shadows */ --drop-shadow-color: rgba(0, 0, 0, 0.08); @@ -519,6 +520,7 @@ --header-selected-fg: #FFFFFF; /* file card background (dark) */ --file-card-bg: #1F2329; + --accordion-item-bg: #373D45; /* accordion item background - more distinguishable */ /* shadows */ --drop-shadow-color: rgba(255, 255, 255, 0.08); diff --git a/frontend/src/core/tools/GetPdfInfo.tsx b/frontend/src/core/tools/GetPdfInfo.tsx new file mode 100644 index 0000000000..aa35fe16bf --- /dev/null +++ b/frontend/src/core/tools/GetPdfInfo.tsx @@ -0,0 +1,188 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import LinkIcon from '@mui/icons-material/Link'; +import { Stack, Group, Divider, Text, UnstyledButton } from '@mantine/core'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { useGetPdfInfoParameters, defaultParameters } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters'; +import GetPdfInfoResults from '@app/components/tools/getPdfInfo/GetPdfInfoResults'; +import { useGetPdfInfoOperation, GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation'; +import GetPdfInfoReportView from '@app/components/tools/getPdfInfo/GetPdfInfoReportView'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import type { PdfInfoReportData } from '@app/types/getPdfInfo'; + +const CHAPTERS = [ + { id: 'summary', labelKey: 'getPdfInfo.summary.title', fallback: 'PDF Summary' }, + { id: 'metadata', labelKey: 'getPdfInfo.sections.metadata', fallback: 'Metadata' }, + { id: 'formFields', labelKey: 'getPdfInfo.sections.formFields', fallback: 'Form Fields' }, + { id: 'basicInfo', labelKey: 'getPdfInfo.sections.basicInfo', fallback: 'Basic Info' }, + { id: 'documentInfo', labelKey: 'getPdfInfo.sections.documentInfo', fallback: 'Document Info' }, + { id: 'compliance', labelKey: 'getPdfInfo.sections.compliance', fallback: 'Compliance' }, + { id: 'encryption', labelKey: 'getPdfInfo.sections.encryption', fallback: 'Encryption' }, + { id: 'permissions', labelKey: 'getPdfInfo.sections.permissions', fallback: 'Permissions' }, + { id: 'toc', labelKey: 'getPdfInfo.sections.tableOfContents', fallback: 'Table of Contents' }, + { id: 'other', labelKey: 'getPdfInfo.sections.other', fallback: 'Other' }, + { id: 'perPage', labelKey: 'getPdfInfo.sections.perPageInfo', fallback: 'Per Page Info' }, +]; + +const GetPdfInfo = (props: BaseToolProps) => { + const { t } = useTranslation(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + } = useToolWorkflow(); + + const REPORT_VIEW_ID = 'getPdfInfoReport'; + const REPORT_WORKBENCH_ID = 'custom:getPdfInfoReport' as const; + const reportIcon = useMemo(() => , []); + + const base = useBaseTool( + 'getPdfInfo', + useGetPdfInfoParameters, + useGetPdfInfoOperation, + props + ); + + const operation = base.operation as GetPdfInfoOperationHook; + const hasResults = operation.results.length > 0; + const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage; + + useEffect(() => { + registerCustomWorkbenchView({ + id: REPORT_VIEW_ID, + workbenchId: REPORT_WORKBENCH_ID, + label: t('getPdfInfo.report.shortTitle', 'PDF Information'), + icon: reportIcon, + component: GetPdfInfoReportView, + }); + + return () => { + clearCustomWorkbenchViewData(REPORT_VIEW_ID); + unregisterCustomWorkbenchView(REPORT_VIEW_ID); + }; + }, [ + clearCustomWorkbenchViewData, + registerCustomWorkbenchView, + reportIcon, + t, + unregisterCustomWorkbenchView, + ]); + + const reportData = useMemo(() => { + if (operation.results.length === 0) return null; + const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now(); + return { + generatedAt, + entries: operation.results, + }; + }, [operation.results]); + + const lastReportGeneratedAtRef = useRef(null); + useEffect(() => { + if (reportData) { + setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData); + const generatedAt = reportData.generatedAt ?? null; + const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current; + if (isNewReport) { + lastReportGeneratedAtRef.current = generatedAt; + if (navigationState.selectedTool === 'getPdfInfo' && navigationState.workbench !== REPORT_WORKBENCH_ID) { + navigationActions.setWorkbench(REPORT_WORKBENCH_ID); + } + } + } else { + clearCustomWorkbenchViewData(REPORT_VIEW_ID); + lastReportGeneratedAtRef.current = null; + } + }, [ + clearCustomWorkbenchViewData, + navigationActions, + navigationState.selectedTool, + navigationState.workbench, + reportData, + setCustomWorkbenchViewData, + ]); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: hasResults, + }, + steps: [ + { + title: t('getPdfInfo.indexTitle', 'Index'), + isVisible: Boolean(reportData), + isCollapsed: false, + content: ( + + {CHAPTERS.map((c, idx) => ( + + { + if (!reportData) return; + setCustomWorkbenchViewData(REPORT_VIEW_ID, { ...reportData, scrollTo: c.id }); + if (navigationState.workbench !== REPORT_WORKBENCH_ID) { + navigationActions.setWorkbench(REPORT_WORKBENCH_ID); + } + }} + style={{ width: '100%', textAlign: 'left', padding: '8px 4px' }} + > + + + + {t(c.labelKey, c.fallback)} + + + + {idx < CHAPTERS.length - 1 && } + + ))} + + ), + }, + { + title: t('getPdfInfo.results', 'Results'), + isVisible: showResultsStep, + isCollapsed: false, + content: ( + + ), + }, + ], + executeButton: { + text: t('getPdfInfo.submit', 'Generate'), + loadingText: t('loading', 'Loading...'), + onClick: base.handleExecute, + disabled: + !base.params.validateParameters() || + !base.hasFiles || + base.operation.isLoading || + !base.endpointEnabled, + isVisible: true, + }, + review: { + isVisible: false, + operation: base.operation, + title: t('getPdfInfo.results', 'Results'), + onUndo: base.handleUndo, + }, + }); +}; + +const GetPdfInfoTool = GetPdfInfo as ToolComponent; +GetPdfInfoTool.tool = () => useGetPdfInfoOperation; +GetPdfInfoTool.getDefaultParameters = () => ({ ...defaultParameters }); + +export default GetPdfInfoTool; + + diff --git a/frontend/src/core/types/getPdfInfo.ts b/frontend/src/core/types/getPdfInfo.ts new file mode 100644 index 0000000000..f489cd99ae --- /dev/null +++ b/frontend/src/core/types/getPdfInfo.ts @@ -0,0 +1,273 @@ +/** Metadata section from PDF */ +export interface PdfMetadata { + Title?: string | null; + Author?: string | null; + Subject?: string | null; + Keywords?: string | null; + Creator?: string | null; + Producer?: string | null; + CreationDate?: string | null; + ModificationDate?: string | null; + [key: string]: unknown; +} + +/** Basic info section */ +export interface PdfBasicInfo { + FileSizeInBytes?: number; + WordCount?: number; + ParagraphCount?: number; + CharacterCount?: number; + Compression?: boolean; + CompressionType?: string; + Language?: string | null; + 'Number of pages'?: number; + TotalImages?: number; + [key: string]: unknown; +} + +/** Document info section */ +export interface PdfDocumentInfo { + 'PDF version'?: string; + Trapped?: string | null; + 'Page Mode'?: string; + [key: string]: unknown; +} + +/** Encryption section */ +export interface PdfEncryption { + IsEncrypted?: boolean; + EncryptionAlgorithm?: string; + KeyLength?: number; + [key: string]: unknown; +} + +/** Permissions section - values are "Allowed" or "Not Allowed" */ +export interface PdfPermissions { + 'Document Assembly'?: 'Allowed' | 'Not Allowed'; + 'Extracting Content'?: 'Allowed' | 'Not Allowed'; + 'Extracting for accessibility'?: 'Allowed' | 'Not Allowed'; + 'Form Filling'?: 'Allowed' | 'Not Allowed'; + 'Modifying'?: 'Allowed' | 'Not Allowed'; + 'Modifying annotations'?: 'Allowed' | 'Not Allowed'; + 'Printing'?: 'Allowed' | 'Not Allowed'; + [key: string]: 'Allowed' | 'Not Allowed' | undefined; +} + +/** Compliance section */ +export interface PdfCompliance { + 'IsPDF/ACompliant'?: boolean; + 'PDF/AConformanceLevel'?: string; + 'IsPDF/AValidated'?: boolean; + 'IsPDF/XCompliant'?: boolean; + 'IsPDF/ECompliant'?: boolean; + 'IsPDF/VTCompliant'?: boolean; + 'IsPDF/UACompliant'?: boolean; + 'IsPDF/BCompliant'?: boolean; + 'IsPDF/SECCompliant'?: boolean; + [key: string]: unknown; +} + +/** Font info within a page */ +export interface PdfFontInfo { + Name?: string; + IsEmbedded?: boolean; + Subtype?: string; + ItalicAngle?: number; + IsItalic?: boolean; + IsBold?: boolean; + IsFixedPitch?: boolean; + IsSerif?: boolean; + IsSymbolic?: boolean; + IsScript?: boolean; + IsNonsymbolic?: boolean; + FontFamily?: string; + FontWeight?: number; + Count?: number; +} + +/** Image info within a page */ +export interface PdfImageInfo { + Width?: number; + Height?: number; + Name?: string; + ColorSpace?: string; +} + +/** Link info within a page */ +export interface PdfLinkInfo { + URI?: string; +} + +/** Annotations info within a page */ +export interface PdfAnnotationsInfo { + AnnotationsCount?: number; + SubtypeCount?: number; + ContentsCount?: number; + [key: string]: unknown; +} + +/** Size/dimensions info within a page */ +export interface PdfSizeInfo { + 'Width (px)'?: string; + 'Height (px)'?: string; + 'Width (in)'?: string; + 'Height (in)'?: string; + 'Width (cm)'?: string; + 'Height (cm)'?: string; + 'Standard Page'?: string; + [key: string]: unknown; +} + +/** XObject counts within a page */ +export interface PdfXObjectCounts { + Image?: number; + Form?: number; + Other?: number; + [key: string]: unknown; +} + +/** ICC Profile info */ +export interface PdfICCProfile { + 'ICC Profile Length'?: number; +} + +/** Page-level information */ +export interface PdfPageInfo { + Size?: PdfSizeInfo; + Rotation?: number; + 'Page Orientation'?: string; + MediaBox?: string; + CropBox?: string; + BleedBox?: string; + TrimBox?: string; + ArtBox?: string; + 'Text Characters Count'?: number; + Annotations?: PdfAnnotationsInfo; + Images?: PdfImageInfo[]; + Links?: PdfLinkInfo[]; + Fonts?: PdfFontInfo[]; + 'Color Spaces & ICC Profiles'?: PdfICCProfile[]; + XObjectCounts?: PdfXObjectCounts; + Multimedia?: Record[]; +} + +/** Per-page info section (keyed by "Page 1", "Page 2", etc.) */ +export interface PdfPerPageInfo { + [pageLabel: string]: PdfPageInfo; +} + +/** Embedded file info */ +export interface PdfEmbeddedFileInfo { + Name?: string; + FileSize?: number; +} + +/** Attachment info */ +export interface PdfAttachmentInfo { + Name?: string; + Description?: string; +} + +/** JavaScript info */ +export interface PdfJavaScriptInfo { + 'JS Name'?: string; + 'JS Script Length'?: number; +} + +/** Layer info */ +export interface PdfLayerInfo { + Name?: string; +} + +/** Structure tree element */ +export interface PdfStructureTreeElement { + Type?: string; + Content?: string; + Children?: PdfStructureTreeElement[]; +} + +/** Other section with miscellaneous data */ +export interface PdfOtherInfo { + Attachments?: PdfAttachmentInfo[]; + EmbeddedFiles?: PdfEmbeddedFileInfo[]; + JavaScript?: PdfJavaScriptInfo[]; + Layers?: PdfLayerInfo[]; + StructureTree?: PdfStructureTreeElement[]; + 'Bookmarks/Outline/TOC'?: PdfTocEntry[]; + XMPMetadata?: string | null; +} + +/** Table of contents bookmark entry */ +export interface PdfTocEntry { + Title?: string; + [key: string]: unknown; +} + +/** Summary data section */ +export interface PdfSummaryData { + encrypted?: boolean; + restrictedPermissions?: string[]; + restrictedPermissionsCount?: number; + standardCompliance?: string; + standardPurpose?: string; + standardValidationPassed?: boolean; +} + +/** Form fields section */ +export type PdfFormFields = Record; + +/** Parsed sections with normalized keys for frontend use */ +export interface ParsedPdfSections { + metadata?: PdfMetadata | null; + formFields?: PdfFormFields | null; + basicInfo?: PdfBasicInfo | null; + documentInfo?: PdfDocumentInfo | null; + compliance?: PdfCompliance | null; + encryption?: PdfEncryption | null; + permissions?: PdfPermissions | null; + toc?: PdfTocEntry[] | null; + other?: PdfOtherInfo | null; + perPage?: PdfPerPageInfo | null; + summaryData?: PdfSummaryData | null; +} + +/** Raw backend response structure */ +export interface PdfInfoBackendData { + Metadata?: PdfMetadata; + FormFields?: PdfFormFields; + BasicInfo?: PdfBasicInfo; + DocumentInfo?: PdfDocumentInfo; + Compliancy?: PdfCompliance; + Encryption?: PdfEncryption; + Permissions?: PdfPermissions; + Other?: PdfOtherInfo; + PerPageInfo?: PdfPerPageInfo; + SummaryData?: PdfSummaryData; + // Legacy/alternative keys for backwards compatibility + 'Form Fields'?: PdfFormFields; + 'Basic Info'?: PdfBasicInfo; + 'Document Info'?: PdfDocumentInfo; + Compliance?: PdfCompliance; + 'Bookmarks/Outline/TOC'?: PdfTocEntry[]; + 'Table of Contents'?: PdfTocEntry[]; + 'Per Page Info'?: PdfPerPageInfo; +} + +export interface PdfInfoReportEntry { + fileId: string; + fileName: string; + fileSize: number | null; + lastModified: number | null; + thumbnailUrl?: string | null; + data: PdfInfoBackendData; + error: string | null; + summaryGeneratedAt?: number; +} + +export interface PdfInfoReportData { + generatedAt: number; + entries: PdfInfoReportEntry[]; +} + +export const INFO_JSON_FILENAME = 'response.json'; +export const INFO_PDF_FILENAME = 'pdf-information-report.pdf'; From c9bf436895ac9444268d5b7163f09db546b5457d Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:37:23 +0000 Subject: [PATCH 05/15] couple of small fixes for text editor (#5155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Workbench.tsx: Allow PDF text editor to handle it's own custom state (this is a bit of a hotfix, we're going to handle the PDF text editors file upload flow better in a future PR). - PdfTextEditorView.tsx: Added a dropzone and some helper text to upload the first file. - PdfTextEditorView.tsx: Hide document view when isConverting is true. Prevents showing stale content from previous file during conversion. - useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of Recommended tools list. Increased visibility of the new feature. - PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on file selection. Stops the "jumpy" behavior when selecting files. - HomePage.tsx: Check specifically for pdfTextEditor tool instead of any custom workbench. Prevents auto-switch to viewer when uploading files while in PDF Text Editor. Screenshot 2025-12-03 at 6 01
14 PM --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- build.gradle | 2 +- .../public/locales/en-GB/translation.toml | 3 + .../src/core/components/layout/Workbench.tsx | 23 +- .../components/shared/AllToolsNavButton.tsx | 18 +- .../shared/NavigationWarningModal.tsx | 6 +- .../core/components/shared/QuickAccessBar.tsx | 17 +- .../quickAccessBar/ActiveToolButton.tsx | 13 +- .../tools/pdfTextEditor/PdfTextEditorView.tsx | 77 ++++- .../src/core/contexts/NavigationContext.tsx | 94 ++++-- .../src/core/contexts/ToolWorkflowContext.tsx | 6 +- .../core/data/useTranslatedToolRegistry.tsx | 34 +- frontend/src/core/pages/HomePage.tsx | 10 +- .../tools/pdfTextEditor/PdfTextEditor.tsx | 295 ++++++++++++++++-- .../tools/pdfTextEditor/pdfTextEditorTypes.ts | 3 + 14 files changed, 492 insertions(+), 109 deletions(-) diff --git a/build.gradle b/build.gradle index 1738c1ef72..2ae1355944 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.0.3' + version = '2.1.0' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index dd30ca4499..2d2fa19c6e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5991,6 +5991,7 @@ earlyAccess = "Early Access" reset = "Reset Changes" downloadJson = "Download JSON" generatePdf = "Generate PDF" +saveChanges = "Save Changes" [pdfTextEditor.options.autoScaleText] title = "Auto-scale text to fit boxes" @@ -6028,6 +6029,8 @@ alpha = "This alpha viewer is still evolving—certain fonts, colours, transpare [pdfTextEditor.empty] title = "No document loaded" subtitle = "Load a PDF or JSON file to begin editing text content." +dropzone = "Drag and drop a PDF or JSON file here, or click to browse" +dropzoneWithFiles = "Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse" [pdfTextEditor.welcomeBanner] title = "Welcome to PDF Text Editor (Early Access)" diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index f6477c67aa..0fa31dd244 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -71,6 +71,20 @@ export default function Workbench() { }; const renderMainContent = () => { + // Check for custom workbench views first + if (!isBaseWorkbench(currentView)) { + const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null); + if (customView) { + // PDF text editor handles its own empty state (shows dropzone when no document) + const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor'; + if (handlesOwnEmptyState || activeFiles.length > 0) { + const CustomComponent = customView.component; + return ; + } + } + } + + // For base workbenches (or custom views that don't handle empty state), show landing page when no files if (activeFiles.length === 0) { return ( view.workbenchId === currentView && view.data != null); - - - if (customView) { - const CustomComponent = customView.component; - return ; - } - } return ; } }; diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index cc7a8777c1..efa9a2a5d3 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from '@app/components/shared/Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; @@ -20,21 +21,36 @@ const AllToolsNavButton: React.FC = ({ }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); + const { hasUnsavedChanges } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); const { getHomeNavigation } = useSidebarNavigation(); - const handleClick = () => { + const performNavigation = () => { setActiveButton('tools'); // Preserve existing behavior used in QuickAccessBar header handleReaderToggle(); handleBackToTools(); }; + const handleClick = () => { + if (hasUnsavedChanges) { + navigationActions.requestNavigation(performNavigation); + return; + } + performNavigation(); + }; + // Do not highlight All Tools when a specific tool is open (indicator is shown) const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker'; const navProps = getHomeNavigation(); const handleNavClick = (e: React.MouseEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + navigationActions.requestNavigation(performNavigation); + return; + } handleUnlessSpecialClick(e, handleClick); }; diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx index faff074279..b8803f1760 100644 --- a/frontend/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx @@ -12,7 +12,7 @@ interface NavigationWarningModalProps { const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => { const { t } = useTranslation(); - const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } = + const { showNavigationWarning, hasUnsavedChanges, pendingNavigation, cancelNavigation, confirmNavigation, setHasUnsavedChanges } = useNavigationGuard(); const handleKeepWorking = () => { @@ -41,7 +41,9 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav }; const BUTTON_WIDTH = "10rem"; - if (!hasUnsavedChanges) { + // Only show modal if there are unsaved changes AND there's an actual pending navigation + // This prevents the modal from showing due to spurious state updates + if (!hasUnsavedChanges || !pendingNavigation) { return null; } diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 029a6d567e..28efd60cbc 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -7,6 +7,7 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi import { useIsOverflowing } from '@app/hooks/useIsOverflowing'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { ButtonConfig } from '@app/types/sidebar'; @@ -32,6 +33,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); + const { hasUnsavedChanges } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); const licenseAlert = useLicenseAlert(); @@ -58,7 +61,7 @@ const QuickAccessBar = forwardRef((_, ref) => { }; // Helper function to render navigation buttons with URL support - const renderNavButton = (config: ButtonConfig, index: number) => { + const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => { const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); // Check if this button has URL navigation support @@ -67,6 +70,14 @@ const QuickAccessBar = forwardRef((_, ref) => { : null; const handleClick = (e?: React.MouseEvent) => { + // If there are unsaved changes and this button should guard navigation, show warning modal + if (shouldGuardNavigation && hasUnsavedChanges) { + e?.preventDefault(); + navigationActions.requestNavigation(() => { + config.onClick(); + }); + return; + } if (navProps && e) { handleUnlessSpecialClick(e, config.onClick); } else { @@ -89,7 +100,7 @@ const QuickAccessBar = forwardRef((_, ref) => { onClick: (e: React.MouseEvent) => handleClick(e), 'aria-label': config.name } : { - onClick: () => handleClick(), + onClick: (e: React.MouseEvent) => handleClick(e), 'aria-label': config.name })} size={isActive ? 'lg' : 'md'} @@ -222,7 +233,7 @@ const QuickAccessBar = forwardRef((_, ref) => { {mainButtons.map((config, index) => ( - {renderNavButton(config, index)} + {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')} ))} diff --git a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx index 273c09d0e1..55b79b26ca 100644 --- a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -16,6 +16,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActionIcon } from '@mantine/core'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import FitText from '@app/components/shared/FitText'; @@ -31,6 +32,8 @@ const NAV_IDS = ['read', 'sign', 'automate']; const ActiveToolButton: React.FC = ({ setActiveButton, tooltipPosition = 'right' }) => { const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); + const { hasUnsavedChanges } = useNavigationState(); + const { actions: navigationActions } = useNavigationActions(); const { getHomeNavigation } = useSidebarNavigation(); // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) @@ -150,10 +153,16 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to component="a" href={getHomeNavigation().href} onClick={(e: React.MouseEvent) => { - handleUnlessSpecialClick(e, () => { + const performNavigation = () => { setActiveButton('tools'); handleBackToTools(); - }); + }; + if (hasUnsavedChanges) { + e.preventDefault(); + navigationActions.requestNavigation(performNavigation); + return; + } + handleUnlessSpecialClick(e, performNavigation); }} size={'lg'} variant="subtle" diff --git a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index 0dbe8c50e1..4eeff845b3 100644 --- a/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -21,6 +21,7 @@ import { Title, Tooltip, } from '@mantine/core'; +import { Dropzone } from '@mantine/dropzone'; import { useTranslation } from 'react-i18next'; import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined'; @@ -32,9 +33,12 @@ import CloseIcon from '@mui/icons-material/Close'; import MergeTypeIcon from '@mui/icons-material/MergeType'; import CallSplitIcon from '@mui/icons-material/CallSplit'; import MoreVertIcon from '@mui/icons-material/MoreVert'; +import UploadFileIcon from '@mui/icons-material/UploadFileOutlined'; +import SaveIcon from '@mui/icons-material/SaveOutlined'; import { Rnd } from 'react-rnd'; import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; +import { useFileContext } from '@app/contexts/FileContext'; import { PdfTextEditorViewData, PdfJsonFont, @@ -313,6 +317,7 @@ type GroupingMode = 'auto' | 'paragraph' | 'singleLine'; const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { const { t } = useTranslation(); + const { activeFiles } = useFileContext(); const [activeGroupId, setActiveGroupId] = useState(null); const [editingGroupId, setEditingGroupId] = useState(null); const [activeImageId, setActiveImageId] = useState(null); @@ -375,6 +380,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { fileName, errorMessage, isGeneratingPdf, + isSavingToWorkbench, isConverting, conversionProgress, hasChanges, @@ -389,11 +395,12 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { onReset, onDownloadJson, onGeneratePdf, - onGeneratePdfForNavigation, + onSaveToWorkbench, onForceSingleTextElementChange, onGroupingModeChange, onMergeGroups, onUngroupGroup, + onLoadFile, } = data; // Define derived variables immediately after props destructuring, before any hooks @@ -1430,7 +1437,8 @@ const selectionToolbarPosition = useMemo(() => { height: '100%', display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', - alignItems: 'start', + gridTemplateRows: '1fr', + alignItems: hasDocument ? 'start' : 'stretch', gap: '1.5rem', }} > @@ -1486,6 +1494,17 @@ const selectionToolbarPosition = useMemo(() => { > {t('pdfTextEditor.actions.generatePdf', 'Generate PDF')} + {fileName && ( @@ -1639,17 +1658,45 @@ const selectionToolbarPosition = useMemo(() => { )} {!hasDocument && !isConverting && ( - - - - - {t('pdfTextEditor.empty.title', 'No document loaded')} - - - {t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')} - - - + + { + if (files.length > 0) { + onLoadFile(files[0]); + } + }} + accept={['application/pdf', 'application/json']} + maxFiles={1} + style={{ + width: '100%', + maxWidth: 480, + minHeight: 200, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: '2px dashed var(--mantine-color-gray-4)', + borderRadius: 'var(--mantine-radius-lg)', + cursor: 'pointer', + transition: 'border-color 150ms ease, background-color 150ms ease', + }} + > + + + + {t('pdfTextEditor.empty.title', 'No document loaded')} + + + {activeFiles.length > 0 + ? t('pdfTextEditor.empty.dropzoneWithFiles', 'Select a file from the Files tab, or drag and drop a PDF or JSON file here, or click to browse') + : t('pdfTextEditor.empty.dropzone', 'Drag and drop a PDF or JSON file here, or click to browse')} + + + + )} {isConverting && ( @@ -1683,7 +1730,7 @@ const selectionToolbarPosition = useMemo(() => { )} - {hasDocument && ( + {hasDocument && !isConverting && ( { {/* Navigation Warning Modal */} ); diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index 500a6db5e1..c11649400a 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useReducer, useCallback } from 'react'; +import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react'; import { WorkbenchType, getDefaultWorkbench } from '@app/types/workbench'; import { ToolId, isValidToolId } from '@app/types/toolId'; import { useToolRegistry } from '@app/contexts/ToolRegistryContext'; @@ -110,8 +110,8 @@ export const NavigationProvider: React.FC<{ const { allTools: toolRegistry } = useToolRegistry(); const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null); - const actions: NavigationContextActions = { - setWorkbench: useCallback((workbench: WorkbenchType) => { + // Memoize individual callbacks + const setWorkbench = useCallback((workbench: WorkbenchType) => { // Check for unsaved changes using registered checker or state const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; console.log('[NavigationContext] setWorkbench:', { @@ -152,13 +152,13 @@ export const NavigationProvider: React.FC<{ } else { dispatch({ type: 'SET_WORKBENCH', payload: { workbench } }); } - }, [state.workbench, state.hasUnsavedChanges]), + }, [state.workbench, state.hasUnsavedChanges]); - setSelectedTool: useCallback((toolId: ToolId | null) => { + const setSelectedTool = useCallback((toolId: ToolId | null) => { dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } }); - }, []), + }, []); - setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { + const setToolAndWorkbench = useCallback((toolId: ToolId | null, workbench: WorkbenchType) => { // Check for unsaved changes using registered checker or state const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges; @@ -177,25 +177,25 @@ export const NavigationProvider: React.FC<{ } else { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } }); } - }, [state.workbench, state.hasUnsavedChanges]), + }, [state.workbench, state.hasUnsavedChanges]); - setHasUnsavedChanges: useCallback((hasChanges: boolean) => { + const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }); - }, []), + }, []); - registerUnsavedChangesChecker: useCallback((checker: () => boolean) => { + const registerUnsavedChangesChecker = useCallback((checker: () => boolean) => { unsavedChangesCheckerRef.current = checker; - }, []), + }, []); - unregisterUnsavedChangesChecker: useCallback(() => { + const unregisterUnsavedChangesChecker = useCallback(() => { unsavedChangesCheckerRef.current = null; - }, []), + }, []); - showNavigationWarning: useCallback((show: boolean) => { + const showNavigationWarning = useCallback((show: boolean) => { dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } }); - }, []), + }, []); - requestNavigation: useCallback((navigationFn: () => void) => { + const requestNavigation = useCallback((navigationFn: () => void) => { if (!state.hasUnsavedChanges) { navigationFn(); return; @@ -203,9 +203,9 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } }); - }, [state.hasUnsavedChanges]), + }, [state.hasUnsavedChanges]); - confirmNavigation: useCallback(() => { + const confirmNavigation = useCallback(() => { console.log('[NavigationContext] confirmNavigation called', { hasPendingNav: !!state.pendingNavigation, currentWorkbench: state.workbench, @@ -218,18 +218,18 @@ export const NavigationProvider: React.FC<{ dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); console.log('[NavigationContext] confirmNavigation completed'); - }, [state.pendingNavigation, state.workbench, state.selectedTool]), + }, [state.pendingNavigation, state.workbench, state.selectedTool]); - cancelNavigation: useCallback(() => { + const cancelNavigation = useCallback(() => { dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } }); dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } }); - }, []), + }, []); - clearToolSelection: useCallback(() => { + const clearToolSelection = useCallback(() => { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); - }, []), + }, []); - handleToolSelect: useCallback((toolId: string) => { + const handleToolSelect = useCallback((toolId: string) => { if (toolId === 'allTools') { dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } }); return; @@ -245,11 +245,40 @@ export const NavigationProvider: React.FC<{ const tool = isValidToolId(toolId)? toolRegistry[toolId] : null; const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench(); - // Validate toolId and convert to ToolId type - const validToolId = isValidToolId(toolId) ? toolId : null; - dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } }); - }, [toolRegistry]) - }; + // Validate toolId and convert to ToolId type + const validToolId = isValidToolId(toolId) ? toolId : null; + dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } }); + }, [toolRegistry]); + + // Memoize the actions object to prevent unnecessary context updates + // This is critical to avoid infinite loops when effects depend on actions + const actions: NavigationContextActions = useMemo(() => ({ + setWorkbench, + setSelectedTool, + setToolAndWorkbench, + setHasUnsavedChanges, + registerUnsavedChangesChecker, + unregisterUnsavedChangesChecker, + showNavigationWarning, + requestNavigation, + confirmNavigation, + cancelNavigation, + clearToolSelection, + handleToolSelect, + }), [ + setWorkbench, + setSelectedTool, + setToolAndWorkbench, + setHasUnsavedChanges, + registerUnsavedChangesChecker, + unregisterUnsavedChangesChecker, + showNavigationWarning, + requestNavigation, + confirmNavigation, + cancelNavigation, + clearToolSelection, + handleToolSelect, + ]); const stateValue: NavigationContextStateValue = { workbench: state.workbench, @@ -259,9 +288,10 @@ export const NavigationProvider: React.FC<{ showNavigationWarning: state.showNavigationWarning }; - const actionsValue: NavigationContextActionsValue = { + // Also memoize the context value to prevent unnecessary re-renders + const actionsValue: NavigationContextActionsValue = useMemo(() => ({ actions - }; + }), [actions]); return ( diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 9717b7f68f..b2c6958018 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -224,11 +224,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { return; } + if (navigationState.pendingNavigation || navigationState.showNavigationWarning) { + return; + } + const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench); if (!currentCustomView || currentCustomView.data == null) { actions.setWorkbench(getDefaultWorkbench()); } - }, [actions, customWorkbenchViews, navigationState.workbench]); + }, [actions, customWorkbenchViews, navigationState.workbench, navigationState.pendingNavigation, navigationState.showNavigationWarning]); // Persisted via PreferencesContext; no direct localStorage writes needed here diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index bc514d879a..0844b94d1a 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -152,6 +152,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { // Proprietary tools (if any) ...proprietaryTools, // Recommended Tools in order + pdfTextEditor: { + icon: , + name: t("home.pdfTextEditor.title", "PDF Text Editor"), + component: PdfTextEditor, + description: t( + "home.pdfTextEditor.desc", + "Review and edit text and images in PDFs with grouped text editing and PDF regeneration" + ), + categoryId: ToolCategoryId.RECOMMENDED_TOOLS, + subcategoryId: SubcategoryId.GENERAL, + maxFiles: 1, + endpoints: ["text-editor-pdf"], + synonyms: getSynonyms(t, "pdfTextEditor"), + supportsAutomate: false, + automationSettings: null, + versionStatus: "alpha", + }, multiTool: { icon: , name: t("home.multiTool.title", "Multi-Tool"), @@ -893,23 +910,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { automationSettings: RedactSingleStepSettings, synonyms: getSynonyms(t, "redact") }, - pdfTextEditor: { - icon: , - name: t("home.pdfTextEditor.title", "PDF Text Editor"), - component: PdfTextEditor, - description: t( - "home.pdfTextEditor.desc", - "Review and edit text and images in PDFs with grouped text editing and PDF regeneration" - ), - categoryId: ToolCategoryId.RECOMMENDED_TOOLS, - subcategoryId: SubcategoryId.GENERAL, - maxFiles: 1, - endpoints: ["text-editor-pdf"], - synonyms: getSynonyms(t, "pdfTextEditor"), - supportsAutomate: false, - automationSettings: null, - versionStatus: "alpha", - }, }; const regularTools = {} as RegularToolRegistry; diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index b629e86a5c..c22a9624d9 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -59,17 +59,21 @@ export default function HomePage() { const prevFileCountRef = useRef(activeFiles.length); // Auto-switch to viewer when going from 0 to 1 file + // Skip this if PDF Text Editor is active - it handles its own empty state useEffect(() => { const prevCount = prevFileCountRef.current; const currentCount = activeFiles.length; if (prevCount === 0 && currentCount === 1) { - actions.setWorkbench('viewer'); - setActiveFileIndex(0); + // PDF Text Editor handles its own empty state with a dropzone + if (selectedToolKey !== 'pdfTextEditor') { + actions.setWorkbench('viewer'); + setActiveFileIndex(0); + } } prevFileCountRef.current = currentCount; - }, [activeFiles.length, actions, setActiveFileIndex]); + }, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandIconSrc = useLogoPath(); diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index 571f3be09a..533dc644b3 100644 --- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next'; import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useFileSelection } from '@app/contexts/FileContext'; +import { useFileSelection, useFileManagement, useFileContext } from '@app/contexts/FileContext'; import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers'; import { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { getDefaultWorkbench } from '@app/types/workbench'; import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants'; import apiClient from '@app/services/apiClient'; import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils'; @@ -208,7 +210,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { } = useToolWorkflow(); const { actions: navigationActions } = useNavigationActions(); const navigationState = useNavigationState(); - const { registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = navigationActions; + const { addFiles } = useFileManagement(); + const { consumeFiles, selectors } = useFileContext(); const [loadedDocument, setLoadedDocument] = useState(null); const [groupsByPage, setGroupsByPage] = useState([]); @@ -217,6 +220,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const [fileName, setFileName] = useState(''); const [errorMessage, setErrorMessage] = useState(null); const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); + const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false); + const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false); const [isConverting, setIsConverting] = useState(false); const [conversionProgress, setConversionProgress] = useState(null); const [forceSingleTextElement, setForceSingleTextElement] = useState(true); @@ -234,6 +239,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const originalGroupsRef = useRef([]); const imagesByPageRef = useRef([]); const autoLoadKeyRef = useRef(null); + const sourceFileIdRef = useRef(null); const loadRequestIdRef = useRef(0); const latestPdfRequestIdRef = useRef(null); const loadedDocumentRef = useRef(null); @@ -279,6 +285,23 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { ); const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]); const hasDocument = loadedDocument !== null; + + // Sync hasChanges to navigation context so navigation guards can block + useEffect(() => { + navigationActions.setHasUnsavedChanges(hasChanges); + return () => { + navigationActions.setHasUnsavedChanges(false); + }; + }, [hasChanges, navigationActions]); + + // Navigate to files view AFTER the unsaved changes state is properly cleared + useEffect(() => { + if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) { + setShouldNavigateAfterSave(false); + navigationActions.setToolAndWorkbench(null, getDefaultWorkbench()); + } + }, [shouldNavigateAfterSave, navigationState.hasUnsavedChanges, navigationActions]); + const viewLabel = useMemo(() => t('pdfTextEditor.viewLabel', 'PDF Editor'), [t]); const { selectedFiles } = useFileSelection(); @@ -720,6 +743,21 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { [groupingMode, resetToDocument, t], ); + // Wrapper for loading files from the dropzone - adds to workbench first + const handleLoadFileFromDropzone = useCallback( + async (file: File) => { + // Add the file to the workbench so it appears in the file list + const addedFiles = await addFiles([file]); + // Capture the file ID for save-to-workbench functionality + if (addedFiles.length > 0 && addedFiles[0].fileId) { + sourceFileIdRef.current = addedFiles[0].fileId; + } + // Then load it into the editor + void handleLoadFile(file); + }, + [addFiles, handleLoadFile], + ); + const handleSelectPage = useCallback((pageIndex: number) => { setSelectedPage(pageIndex); // Trigger lazy loading for images on the selected page @@ -1122,6 +1160,229 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { t, ]); + // Save changes to workbench (replaces the original file with edited version) + const handleSaveToWorkbench = useCallback(async () => { + setIsSavingToWorkbench(true); + + try { + if (!sourceFileIdRef.current) { + console.warn('[PdfTextEditor] No source file ID available for save to workbench'); + // Fall back to generating PDF download if no source file + await handleGeneratePdf(true); + return; + } + + const parentStub = selectors.getStirlingFileStub(sourceFileIdRef.current as any); + if (!parentStub) { + console.warn('[PdfTextEditor] Could not find parent stub for save to workbench'); + await handleGeneratePdf(true); + return; + } + + const ensureImagesForPages = async (pageIndices: number[]) => { + const uniqueIndices = Array.from(new Set(pageIndices)).filter((index) => index >= 0); + if (uniqueIndices.length === 0) { + return; + } + + for (const index of uniqueIndices) { + if (!loadedImagePagesRef.current.has(index)) { + await loadImagesForPage(index); + } + } + + const maxWaitTime = 15000; + const pollInterval = 150; + const startWait = Date.now(); + while (Date.now() - startWait < maxWaitTime) { + const allLoaded = uniqueIndices.every( + (index) => + loadedImagePagesRef.current.has(index) && + imagesByPageRef.current[index] !== undefined, + ); + const anyLoading = uniqueIndices.some((index) => + loadingImagePagesRef.current.has(index), + ); + if (allLoaded && !anyLoading) { + return; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + const missing = uniqueIndices.filter( + (index) => !loadedImagePagesRef.current.has(index), + ); + if (missing.length > 0) { + throw new Error( + `Failed to load images for pages ${missing.map((i) => i + 1).join(', ')}`, + ); + } + }; + + const currentDoc = loadedDocumentRef.current; + const totalPages = currentDoc?.pages?.length ?? 0; + const currentDirtyPages = getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current); + const dirtyPageIndices = currentDirtyPages + .map((isDirty, index) => (isDirty ? index : -1)) + .filter((index) => index >= 0); + + let pdfBlob: Blob; + let downloadName: string; + + const canUseIncremental = + isLazyMode && + cachedJobId && + dirtyPageIndices.length > 0 && + dirtyPageIndices.length < totalPages; + + if (canUseIncremental) { + await ensureImagesForPages(dirtyPageIndices); + + try { + const payload = buildPayload(); + if (!payload) { + throw new Error('Failed to build payload'); + } + + const { document, filename } = payload; + const dirtyPageSet = new Set(dirtyPageIndices); + const partialPages = + document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? []; + + const partialDocument: PdfJsonDocument = { + metadata: document.metadata, + xmpMetadata: document.xmpMetadata, + fonts: document.fonts, + lazyImages: true, + pages: partialPages, + }; + + const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); + const expectedName = `${baseName || 'document'}.pdf`; + const response = await apiClient.post( + `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, + partialDocument, + { + responseType: 'blob', + }, + ); + + const contentDisposition = response.headers?.['content-disposition'] ?? ''; + const detectedName = getFilenameFromHeaders(contentDisposition); + downloadName = detectedName || expectedName; + pdfBlob = response.data; + } catch (incrementalError) { + console.warn( + '[handleSaveToWorkbench] Incremental export failed, falling back to full export', + incrementalError, + ); + // Fall through to full export + if (isLazyMode && totalPages > 0) { + const allPageIndices = Array.from({ length: totalPages }, (_, index) => index); + await ensureImagesForPages(allPageIndices); + } + + const payload = buildPayload(); + if (!payload) { + throw new Error('Failed to build payload'); + } + + const { document, filename } = payload; + const serialized = JSON.stringify(document); + const jsonFile = new File([serialized], filename, { type: 'application/json' }); + + const formData = new FormData(); + formData.append('fileInput', jsonFile); + const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, { + responseType: 'blob', + }); + + const contentDisposition = response.headers?.['content-disposition'] ?? ''; + const detectedName = getFilenameFromHeaders(contentDisposition); + const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); + downloadName = detectedName || `${baseName || 'document'}.pdf`; + pdfBlob = response.data; + } + } else { + if (isLazyMode && totalPages > 0) { + const allPageIndices = Array.from({ length: totalPages }, (_, index) => index); + await ensureImagesForPages(allPageIndices); + } + + const payload = buildPayload(); + if (!payload) { + throw new Error('Failed to build payload'); + } + + const { document, filename } = payload; + const serialized = JSON.stringify(document); + const jsonFile = new File([serialized], filename, { type: 'application/json' }); + + const formData = new FormData(); + formData.append('fileInput', jsonFile); + const response = await apiClient.post(CONVERSION_ENDPOINTS['text-editor-pdf'], formData, { + responseType: 'blob', + }); + + const contentDisposition = response.headers?.['content-disposition'] ?? ''; + const detectedName = getFilenameFromHeaders(contentDisposition); + const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); + downloadName = detectedName || `${baseName || 'document'}.pdf`; + pdfBlob = response.data; + } + + // Create the new PDF file + const pdfFile = new File([pdfBlob], downloadName, { type: 'application/pdf' }); + + // Create StirlingFile and stub for the output + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [pdfFile], + parentStub, + 'pdfTextEditor', + ); + + // Replace the original file with the edited version + await consumeFiles([sourceFileIdRef.current as any], stirlingFiles, stubs); + + // Update the source file ID to point to the new file + sourceFileIdRef.current = stubs[0].id; + + // Clear the unsaved changes flag - this will trigger the useEffect to navigate + // once React has processed the state update + navigationActions.setHasUnsavedChanges(false); + setErrorMessage(null); + + // Set flag to trigger navigation after state update is processed + setShouldNavigateAfterSave(true); + } catch (error: any) { + console.error('Failed to save to workbench', error); + const message = + error?.response?.data || + error?.message || + t('pdfTextEditor.errors.pdfConversion', 'Unable to save changes to workbench.'); + const msgString = typeof message === 'string' ? message : String(message); + setErrorMessage(msgString); + if (onError) { + onError(msgString); + } + } finally { + setIsSavingToWorkbench(false); + } + }, [ + buildPayload, + cachedJobId, + consumeFiles, + groupsByPage, + handleGeneratePdf, + imagesByPage, + isLazyMode, + loadImagesForPage, + navigationActions, + onError, + selectors, + t, + ]); + const requestPagePreview = useCallback( async (pageIndex: number, scale: number) => { if (!hasVectorPreview || !pdfDocumentRef.current) { @@ -1260,6 +1521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { fileName, errorMessage, isGeneratingPdf, + isSavingToWorkbench, isConverting, conversionProgress, hasChanges, @@ -1278,15 +1540,19 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { // Generate PDF without triggering tool completion await handleGeneratePdf(true); }, + onSaveToWorkbench: handleSaveToWorkbench, onForceSingleTextElementChange: setForceSingleTextElement, onGroupingModeChange: setGroupingMode, onMergeGroups: handleMergeGroups, onUngroupGroup: handleUngroupGroup, + onLoadFile: handleLoadFileFromDropzone, }), [ handleMergeGroups, handleUngroupGroup, handleImageTransform, + handleSaveToWorkbench, imagesByPage, + isSavingToWorkbench, pagePreviews, dirtyPages, errorMessage, @@ -1311,6 +1577,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { groupingMode, requestPagePreview, setForceSingleTextElement, + handleLoadFileFromDropzone, ]); const latestViewDataRef = useRef(viewData); @@ -1326,6 +1593,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { useEffect(() => { if (selectedFiles.length === 0) { autoLoadKeyRef.current = null; + sourceFileIdRef.current = null; return; } @@ -1344,6 +1612,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { } autoLoadKeyRef.current = fileKey; + // Capture the source file ID for save-to-workbench functionality + sourceFileIdRef.current = (file as any).fileId ?? null; void handleLoadFile(file); }, [selectedFiles, navigationState.selectedTool, handleLoadFile]); @@ -1398,27 +1668,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { // The workbench should be set when the tool is selected via proper channels // (tool registry, tool picker, etc.) - not forced here - // Keep hasChanges in a ref for the checker to access - const hasChangesRef = useRef(hasChanges); - useEffect(() => { - hasChangesRef.current = hasChanges; - console.log('[PdfTextEditor] hasChanges updated to:', hasChanges); - }, [hasChanges]); - - // Register unsaved changes checker for navigation guard - useEffect(() => { - const checker = () => { - console.log('[PdfTextEditor] Checking unsaved changes:', hasChangesRef.current); - return hasChangesRef.current; - }; - registerUnsavedChangesChecker(checker); - console.log('[PdfTextEditor] Registered unsaved changes checker'); - return () => { - console.log('[PdfTextEditor] Unregistered unsaved changes checker'); - unregisterUnsavedChangesChecker(); - }; - }, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); - const lastSentViewDataRef = useRef(null); useEffect(() => { diff --git a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts index 8439bb4c18..3dd45a4657 100644 --- a/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts +++ b/frontend/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts @@ -221,8 +221,11 @@ export interface PdfTextEditorViewData { onDownloadJson: () => void; onGeneratePdf: () => void; onGeneratePdfForNavigation: () => Promise; + onSaveToWorkbench: () => Promise; + isSavingToWorkbench: boolean; onForceSingleTextElementChange: (value: boolean) => void; onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void; onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean; onUngroupGroup: (pageIndex: number, groupId: string) => boolean; + onLoadFile: (file: File) => void; } From 7459463a3ccc133368e5d263e5bfe992d7febdf2 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 3 Dec 2025 21:12:29 +0000 Subject: [PATCH 06/15] V2 sso in server plan (#5158) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- ...stomSaml2AuthenticationSuccessHandler.java | 10 ++--- .../service/UserLicenseSettingsService.java | 43 +++++++++++++++---- .../public/locales/en-GB/translation.toml | 4 +- .../configSections/providerDefinitions.ts | 6 ++- .../proprietary/constants/planConstants.ts | 11 +++-- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index 0f350d7b4d..b342fdcb46 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -67,19 +67,19 @@ public class CustomSaml2AuthenticationSuccessHandler boolean userExists = userService.usernameExistsIgnoreCase(username); - // Check if user is eligible for SAML (grandfathered or system has paid license) + // Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license) if (userExists) { stirling.software.proprietary.security.model.User user = userService.findByUsernameIgnoreCase(username).orElse(null); - if (user != null && !licenseSettingsService.isOAuthEligible(user)) { - // User is not grandfathered and no paid license - block SAML login + if (user != null && !licenseSettingsService.isSamlEligible(user)) { + // User is not grandfathered and no ENTERPRISE license - block SAML login response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; } - } else if (!licenseSettingsService.isOAuthEligible(null)) { - // No existing user and no paid license -> block auto creation + } else if (!licenseSettingsService.isSamlEligible(null)) { + // No existing user and no ENTERPRISE license -> block auto creation response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 1cddefde3b..d3bade89c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -331,17 +331,17 @@ public class UserLicenseSettingsService { } /** - * Checks if a user is eligible to use OAuth/SAML authentication. + * Checks if a user is eligible to use OAuth authentication. * *

A user is eligible if: * *

    *
  • They are grandfathered for OAuth (existing user before policy change), OR - *
  • The system has an ENTERPRISE license (SSO is enterprise-only) + *
  • The system has a paid license (SERVER or ENTERPRISE) *
* * @param user The user to check - * @return true if the user can use OAuth/SAML + * @return true if the user can use OAuth */ public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) { // Grandfathered users always have OAuth access @@ -350,10 +350,36 @@ public class UserLicenseSettingsService { return true; } - // Users can use OAuth/SAML only if system has ENTERPRISE license - boolean hasEnterpriseLicense = hasEnterpriseLicense(); - log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense); - return hasEnterpriseLicense; + // Users can use OAuth with SERVER or ENTERPRISE license + boolean hasPaid = hasPaidLicense(); + log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaid); + return hasPaid; + } + + /** + * Checks if a user is eligible to use SAML authentication. + * + *

A user is eligible if: + * + *

    + *
  • They are grandfathered for OAuth (existing user before policy change), OR + *
  • The system has an ENTERPRISE license (SAML is enterprise-only) + *
+ * + * @param user The user to check + * @return true if the user can use SAML + */ + public boolean isSamlEligible(stirling.software.proprietary.security.model.User user) { + // Grandfathered users always have SAML access + if (user != null && user.isOauthGrandfathered()) { + log.debug("User {} is grandfathered for SAML", user.getUsername()); + return true; + } + + // Users can use SAML only with ENTERPRISE license + boolean hasEnterprise = hasEnterpriseLicense(); + log.debug("SAML eligibility check: hasEnterpriseLicense={}", hasEnterprise); + return hasEnterprise; } /** @@ -500,8 +526,7 @@ public class UserLicenseSettingsService { } /** - * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO - * (OAuth/SAML). + * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SAML. * * @return true if ENTERPRISE license is active */ diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 2d2fa19c6e..b68a380d64 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -3539,8 +3539,8 @@ signinTitle = "Please sign in" ssoSignIn = "Login via Single Sign-on" oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create User Disabled" oAuth2AdminBlockedUser = "Registration or logging in of non-registered users is currently blocked. Please contact the administrator." -oAuth2RequiresLicense = "OAuth/SSO login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan." -saml2RequiresLicense = "SAML login requires a paid license (Server or Enterprise). Please contact the administrator to upgrade your plan." +oAuth2RequiresLicense = "OAuth/SSO login requires a Server or Enterprise license. Please contact the administrator to upgrade your plan." +saml2RequiresLicense = "SAML login requires an Enterprise license. Please contact the administrator to upgrade your plan." maxUsersReached = "Maximum number of users reached for your current license. Please contact the administrator to upgrade your plan or add more seats." oauth2RequestNotFound = "Authorization request not found" oauth2InvalidUserInfoResponse = "Invalid User Info Response" diff --git a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts index f51f997c72..f9d4af7c4e 100644 --- a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts +++ b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts @@ -97,6 +97,7 @@ export const OAUTH2_PROVIDERS: Provider[] = [ icon: 'key-rounded', type: 'oauth2', scope: 'SSO', + businessTier: false, // Server tier - OAuth2/OIDC SSO fields: [ { key: 'issuer', @@ -141,6 +142,7 @@ export const GENERIC_OAUTH2_PROVIDER: Provider = { icon: 'link-rounded', type: 'oauth2', scope: 'SSO', + businessTier: false, // Server tier - OAuth2/OIDC SSO fields: [ { key: 'enabled', @@ -262,8 +264,8 @@ export const SAML2_PROVIDER: Provider = { name: 'SAML2', icon: 'verified-user-rounded', type: 'saml2', - scope: 'SSO', - businessTier: true, + scope: 'SSO (SAML)', + businessTier: true, // Enterprise tier - SAML only fields: [ { key: 'enabled', diff --git a/frontend/src/proprietary/constants/planConstants.ts b/frontend/src/proprietary/constants/planConstants.ts index ab14a2c550..f7073d25cf 100644 --- a/frontend/src/proprietary/constants/planConstants.ts +++ b/frontend/src/proprietary/constants/planConstants.ts @@ -19,6 +19,7 @@ export const PLAN_FEATURES = { { name: 'Editing text in pdfs', included: false }, { name: 'Users limited to seats', included: false }, { name: 'SSO', included: false }, + { name: 'SAML', included: false }, { name: 'Auditing', included: false }, { name: 'Usage tracking', included: false }, { name: 'Prometheus Support', included: false }, @@ -37,7 +38,8 @@ export const PLAN_FEATURES = { { name: 'External Database', included: true }, { name: 'Editing text in pdfs', included: true }, { name: 'Users limited to seats', included: false }, - { name: 'SSO', included: false }, + { name: 'SSO', included: true }, + { name: 'SAML', included: false }, { name: 'Auditing', included: false }, { name: 'Usage tracking', included: false }, { name: 'Prometheus Support', included: false }, @@ -57,6 +59,7 @@ export const PLAN_FEATURES = { { name: 'Editing text in pdfs', included: true }, { name: 'Users limited to seats', included: true }, { name: 'SSO', included: true }, + { name: 'SAML', included: true }, { name: 'Auditing', included: true }, { name: 'Usage tracking', included: true }, { name: 'Prometheus Support', included: true }, @@ -74,6 +77,7 @@ export const PLAN_HIGHLIGHTS = { 'Self-hosted on your infrastructure', 'Unlimited users', 'Advanced integrations', + 'SSO (OAuth2/OIDC)', 'Editing text in PDFs', 'Cancel anytime' ], @@ -81,17 +85,18 @@ export const PLAN_HIGHLIGHTS = { 'Self-hosted on your infrastructure', 'Unlimited users', 'Advanced integrations', + 'SSO (OAuth2/OIDC)', 'Editing text in PDFs', 'Save with annual billing' ], ENTERPRISE_MONTHLY: [ - 'Enterprise features (SSO, Auditing)', + 'Enterprise features (SAML, Auditing)', 'Usage tracking & Prometheus', 'Custom PDF metadata', 'Per-seat licensing' ], ENTERPRISE_YEARLY: [ - 'Enterprise features (SSO, Auditing)', + 'Enterprise features (SAML, Auditing)', 'Usage tracking & Prometheus', 'Custom PDF metadata', 'Save with annual billing' From c6b4a2b141a399f410cd94c0e590ee25f4389d89 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:48:19 +0000 Subject: [PATCH 07/15] Desktop to match normal login screens (#5122)1 Also fixed issue with csrf Also fixed issue with rust keychain --------- Co-authored-by: James Brunton --- .../common/model/ApplicationProperties.java | 1 - .../common/service/PostHogService.java | 5 +- .../software/SPDF/config/InitialSetup.java | 16 +- .../controller/api/SettingsController.java | 7 - .../web/ReactRoutingController.java | 10 +- .../src/main/resources/settings.yml.template | 1 - .../configuration/SecurityConfiguration.java | 50 +--- .../public/locales/en-GB/translation.toml | 9 + frontend/src-tauri/Cargo.lock | 19 +- frontend/src-tauri/Cargo.toml | 2 +- frontend/src-tauri/src/commands/auth.rs | 67 ++--- .../SetupWizard/DesktopAuthLayout.tsx | 72 ++++++ .../SetupWizard/DesktopOAuthButtons.tsx | 110 +++++++++ .../components/SetupWizard/LoginForm.tsx | 225 ----------------- .../components/SetupWizard/ModeSelection.tsx | 72 ------ .../SetupWizard/SaaSLoginScreen.tsx | 95 ++++++++ .../components/SetupWizard/SelfHostedLink.tsx | 25 ++ .../SetupWizard/SelfHostedLoginScreen.tsx | 105 ++++++++ .../SetupWizard/ServerSelection.tsx | 91 ++++++- .../SetupWizard/ServerSelectionScreen.tsx | 34 +++ .../components/SetupWizard/SetupWizard.css | 23 -- .../desktop/components/SetupWizard/index.tsx | 230 ++++++++---------- frontend/src/desktop/services/apiClient.ts | 2 +- .../src/desktop/services/apiClientSetup.ts | 14 +- frontend/src/desktop/services/authService.ts | 74 ++++-- .../desktop/services/connectionModeService.ts | 1 + .../src/desktop/services/tauriHttpClient.ts | 7 +- .../configSections/AdminSecuritySection.tsx | 19 -- 28 files changed, 779 insertions(+), 607 deletions(-) create mode 100644 frontend/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx delete mode 100644 frontend/src/desktop/components/SetupWizard/LoginForm.tsx delete mode 100644 frontend/src/desktop/components/SetupWizard/ModeSelection.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/SelfHostedLink.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx create mode 100644 frontend/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx delete mode 100644 frontend/src/desktop/components/SetupWizard/SetupWizard.css diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 6a6ee8453b..f6afa62ea4 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -112,7 +112,6 @@ public class ApplicationProperties { @Data public static class Security { private Boolean enableLogin; - private Boolean csrfDisabled; private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); private SAML2 saml2 = new SAML2(); diff --git a/app/common/src/main/java/stirling/software/common/service/PostHogService.java b/app/common/src/main/java/stirling/software/common/service/PostHogService.java index 310fc43ab2..786c04a437 100644 --- a/app/common/src/main/java/stirling/software/common/service/PostHogService.java +++ b/app/common/src/main/java/stirling/software/common/service/PostHogService.java @@ -254,10 +254,7 @@ public class PostHogService { properties, "security_enableLogin", applicationProperties.getSecurity().getEnableLogin()); - addIfNotEmpty( - properties, - "security_csrfDisabled", - applicationProperties.getSecurity().getCsrfDisabled()); + addIfNotEmpty(properties, "security_csrfDisabled", true); addIfNotEmpty( properties, "security_loginAttemptCount", diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 0a63a6f486..ef592cb550 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -34,7 +34,6 @@ public class InitialSetup { public void init() throws IOException { initUUIDKey(); initSecretKey(); - initEnableCSRFSecurity(); initLegalUrls(); initSetAppVersion(); GeneralUtils.extractPipeline(); @@ -59,19 +58,6 @@ public class InitialSetup { applicationProperties.getAutomaticallyGenerated().setKey(secretKey); } } - - public void initEnableCSRFSecurity() throws IOException { - if (GeneralUtils.isVersionHigher( - "0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) { - Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled(); - if (!csrf) { - GeneralUtils.saveKeyToSettings("security.csrfDisabled", false); - GeneralUtils.saveKeyToSettings("system.enableAnalytics", true); - applicationProperties.getSecurity().setCsrfDisabled(false); - } - } - } - public void initLegalUrls() throws IOException { // Initialize Terms and Conditions String termsUrl = applicationProperties.getLegal().getTermsAndConditions(); @@ -95,7 +81,7 @@ public class InitialSetup { isNewServer = existingVersion == null || existingVersion.isEmpty() - || existingVersion.equals("0.0.0"); + || "0.0.0".equals(existingVersion); String appVersion = "0.0.0"; Resource resource = new ClassPathResource("version.properties"); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java index 9657d8f150..1d9f63818b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java @@ -124,7 +124,6 @@ public class SettingsController { ApplicationProperties.Security security = applicationProperties.getSecurity(); settings.put("enableLogin", security.getEnableLogin()); - settings.put("csrfDisabled", security.getCsrfDisabled()); settings.put("loginMethod", security.getLoginMethod()); settings.put("loginAttemptCount", security.getLoginAttemptCount()); settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes()); @@ -159,12 +158,6 @@ public class SettingsController { .getSecurity() .setEnableLogin((Boolean) settings.get("enableLogin")); } - if (settings.containsKey("csrfDisabled")) { - GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled")); - applicationProperties - .getSecurity() - .setCsrfDisabled((Boolean) settings.get("csrfDisabled")); - } if (settings.containsKey("loginMethod")) { GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod")); applicationProperties diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 7741220f24..6373e07520 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -4,8 +4,6 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import jakarta.annotation.PostConstruct; - import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; @@ -13,6 +11,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; @Controller @@ -63,9 +62,10 @@ public class ReactRoutingController { } } - @GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) - public ResponseEntity serveIndexHtml(HttpServletRequest request) - throws IOException { + @GetMapping( + value = {"/", "/index.html"}, + produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveIndexHtml(HttpServletRequest request) throws IOException { if (indexHtmlExists && cachedIndexHtml != null) { return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5ea28f71e8..4c2ec003e3 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -12,7 +12,6 @@ security: enableLogin: true # set to 'true' to enable login - csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index ab1e4934d8..2a0cd57348 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -1,7 +1,6 @@ package stirling.software.proprietary.security.configuration; import java.util.List; -import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.web.cors.CorsConfiguration; @@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi import stirling.software.proprietary.security.filter.IPRateLimitingFilter; import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; -import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler; @@ -198,9 +194,7 @@ public class SecurityConfiguration { http.cors(cors -> cors.disable()); } - if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { - http.csrf(CsrfConfigurer::disable); - } + http.csrf(CsrfConfigurer::disable); if (loginEnabledValue) { boolean v2Enabled = appConfig.v2Enabled(); @@ -210,48 +204,6 @@ public class SecurityConfiguration { .addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class); - if (!securityProperties.getCsrfDisabled()) { - CookieCsrfTokenRepository cookieRepo = - CookieCsrfTokenRepository.withHttpOnlyFalse(); - CsrfTokenRequestAttributeHandler requestHandler = - new CsrfTokenRequestAttributeHandler(); - requestHandler.setCsrfRequestAttributeName(null); - http.csrf( - csrf -> - csrf.ignoringRequestMatchers( - request -> { - String uri = request.getRequestURI(); - - // Ignore CSRF for auth endpoints - if (uri.startsWith("/api/v1/auth/")) { - return true; - } - - String apiKey = request.getHeader("X-API-KEY"); - // If there's no API key, don't ignore CSRF - // (return false) - if (apiKey == null || apiKey.trim().isEmpty()) { - return false; - } - // Validate API key using existing UserService - try { - Optional user = - userService.getUserByApiKey(apiKey); - // If API key is valid, ignore CSRF (return - // true) - // If API key is invalid, don't ignore CSRF - // (return false) - return user.isPresent(); - } catch (Exception e) { - // If there's any error validating the API - // key, don't ignore CSRF - return false; - } - }) - .csrfTokenRepository(cookieRepo) - .csrfTokenRequestHandler(requestHandler)); - } - http.sessionManagement( sessionManagement -> { if (v2Enabled) { diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index b68a380d64..c60df3a850 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5901,6 +5901,7 @@ subtitle = "Sign in with your Stirling account" [setup.selfhosted] title = "Sign in to Server" subtitle = "Enter your server credentials" +link = "or connect to a self-hosted account" [setup.server] title = "Connect to Server" @@ -5919,6 +5920,14 @@ description = "Enter the full URL of your self-hosted Stirling PDF server" emptyUrl = "Please enter a server URL" unreachable = "Could not connect to server" testFailed = "Connection test failed" +configFetch = "Failed to fetch server configuration. Please check the URL and try again." + +[setup.server.error.securityDisabled] +title = "Login Not Enabled" +body = "This server does not have login enabled. To connect to this server, you must enable authentication:" +step1 = "Set DOCKER_ENABLE_SECURITY=true in your environment" +step2 = "Or set security.enableLogin=true in settings.yml" +step3 = "Restart the server" [setup.login] title = "Sign In" diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9d2395e2de..9719752dc8 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2152,7 +2152,11 @@ version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ + "byteorder", "log", + "security-framework 2.11.1", + "security-framework 3.5.1", + "windows-sys 0.60.2", "zeroize", ] @@ -2378,7 +2382,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -3841,6 +3845,19 @@ dependencies = [ "security-framework-sys", ] +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework-sys" version = "2.15.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 6884bd1788..dc84ad8a23 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -32,7 +32,7 @@ tauri-plugin-http = "2.4.4" tauri-plugin-single-instance = "2.0.1" tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" -keyring = "3.6.1" +keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } tiny_http = "0.12" diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 30ec0d6c40..3e75b452ec 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -1,4 +1,4 @@ -use keyring::Entry; +use keyring::{Entry}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; use tauri::AppHandle; @@ -21,53 +21,70 @@ pub struct UserInfo { } fn get_keyring_entry() -> Result { - Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY) - .map_err(|e| format!("Failed to access keyring: {}", e)) + log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY); + let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY) + .map_err(|e| { + log::error!("Failed to create keyring entry: {}", e); + format!("Failed to access keyring: {}", e) + })?; + log::debug!("Keyring entry created successfully"); + Ok(entry) } #[tauri::command] pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> { - log::info!("Saving auth token to keyring"); + if token.is_empty() { + log::warn!("Attempted to save empty auth token"); + return Err("Token cannot be empty".to_string()); + } let entry = get_keyring_entry()?; entry .set_password(&token) - .map_err(|e| format!("Failed to save token to keyring: {}", e))?; + .map_err(|e| { + log::error!("Failed to set password in keyring: {}", e); + format!("Failed to save token to keyring: {}", e) + })?; + + // Verify the save worked + match entry.get_password() { + Ok(retrieved_token) => { + if retrieved_token != token { + log::error!("Token verification failed: Retrieved token doesn't match"); + return Err("Token verification failed after save".to_string()); + } + } + Err(e) => { + log::error!("Token verification failed: {}", e); + return Err(format!("Token verification failed: {}", e)); + } + } - log::info!("Auth token saved successfully"); Ok(()) } #[tauri::command] pub async fn get_auth_token(_app_handle: AppHandle) -> Result, String> { - log::debug!("Retrieving auth token from keyring"); - let entry = get_keyring_entry()?; match entry.get_password() { Ok(token) => Ok(Some(token)), Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(format!("Failed to retrieve token: {}", e)), + Err(e) => { + log::error!("Failed to retrieve token from keyring: {}", e); + Err(format!("Failed to retrieve token: {}", e)) + }, } } #[tauri::command] pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> { - log::info!("Clearing auth token from keyring"); - let entry = get_keyring_entry()?; // Delete the token - ignore error if it doesn't exist match entry.delete_credential() { - Ok(_) => { - log::info!("Auth token cleared successfully"); - Ok(()) - } - Err(keyring::Error::NoEntry) => { - log::info!("Auth token was already cleared"); - Ok(()) - } + Ok(_) | Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(format!("Failed to clear token: {}", e)), } } @@ -78,8 +95,6 @@ pub async fn save_user_info( username: String, email: Option, ) -> Result<(), String> { - log::info!("Saving user info for: {}", username); - let user_info = UserInfo { username, email }; let store = app_handle @@ -96,7 +111,6 @@ pub async fn save_user_info( .save() .map_err(|e| format!("Failed to save store: {}", e))?; - log::info!("User info saved successfully"); Ok(()) } @@ -117,8 +131,6 @@ pub async fn get_user_info(app_handle: AppHandle) -> Result, St #[tauri::command] pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> { - log::info!("Clearing user info"); - let store = app_handle .store(STORE_FILE) .map_err(|e| format!("Failed to access store: {}", e))?; @@ -129,7 +141,6 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> { .save() .map_err(|e| format!("Failed to save store: {}", e))?; - log::info!("User info cleared successfully"); Ok(()) } @@ -186,12 +197,8 @@ pub async fn login( supabase_key: String, saas_server_url: String, ) -> Result { - log::info!("Login attempt for user: {} to server: {}", username, server_url); - // Detect if this is Supabase (SaaS) or Spring Boot (self-hosted) - // Compare against the configured SaaS server URL let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/'); - log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" }); // Create HTTP client let client = reqwest::Client::new(); @@ -248,8 +255,6 @@ pub async fn login( .or_else(|| email.clone()) .unwrap_or_else(|| username); - log::info!("Supabase login successful for user: {}", username); - Ok(LoginResponse { token: login_response.access_token, username, diff --git a/frontend/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx b/frontend/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx new file mode 100644 index 0000000000..92558857b4 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginRightCarousel from '@app/components/shared/LoginRightCarousel'; +import buildLoginSlides from '@app/components/shared/loginSlides'; +import styles from '@app/routes/authShared/AuthLayout.module.css'; +import { useLogoVariant } from '@app/hooks/useLogoVariant'; + +interface DesktopAuthLayoutProps { + children: React.ReactNode; +} + +export const DesktopAuthLayout: React.FC = ({ children }) => { + const { t } = useTranslation(); + const cardRef = useRef(null); + const [hideRightPanel, setHideRightPanel] = useState(false); + const logoVariant = useLogoVariant(); + const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]); + + // Force light mode on auth pages + useEffect(() => { + const htmlElement = document.documentElement; + const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme'); + + // Set light mode + htmlElement.setAttribute('data-mantine-color-scheme', 'light'); + + // Cleanup: restore previous theme when leaving auth pages + return () => { + if (previousColorScheme) { + htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme); + } + }; + }, []); + + useEffect(() => { + const update = () => { + // Use viewport to avoid hysteresis when the card is already in single-column mode + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw) + const columnWidth = cardWidthIfTwoCols / 2; + const tooNarrow = columnWidth < 470; + const tooShort = viewportHeight < 740; + setHideRightPanel(tooNarrow || tooShort); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('orientationchange', update); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('orientationchange', update); + }; + }, []); + + return ( +
+
+
+
+ {children} +
+
+ {!hideRightPanel && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx new file mode 100644 index 0000000000..49b55a3869 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx @@ -0,0 +1,110 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { authService, UserInfo } from '@app/services/authService'; +import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml'; +import { BASE_PATH } from '@app/constants/app'; +import '@app/routes/authShared/auth.css'; + +export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc'; + +interface DesktopOAuthButtonsProps { + onOAuthSuccess: (userInfo: UserInfo) => Promise; + onError: (error: string) => void; + isDisabled: boolean; + serverUrl: string; + providers: OAuthProvider[]; +} + +export const DesktopOAuthButtons: React.FC = ({ + onOAuthSuccess, + onError, + isDisabled, + serverUrl, + providers, +}) => { + const { t } = useTranslation(); + const [oauthLoading, setOauthLoading] = useState(false); + + const handleOAuthLogin = async (provider: OAuthProvider) => { + // Prevent concurrent OAuth attempts + if (oauthLoading || isDisabled) { + return; + } + + try { + setOauthLoading(true); + + // Build callback page HTML with translations and dark mode support + const successHtml = buildOAuthCallbackHtml({ + title: t('oauth.success.title', 'Authentication Successful'), + message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'), + isError: false, + }); + + const errorHtml = buildOAuthCallbackHtml({ + title: t('oauth.error.title', 'Authentication Failed'), + message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'), + isError: true, + errorPlaceholder: true, // {error} will be replaced by Rust + }); + + const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml); + + // Call the onOAuthSuccess callback to complete setup + await onOAuthSuccess(userInfo); + } catch (error) { + console.error('OAuth login failed:', error); + + const errorMessage = error instanceof Error + ? error.message + : t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.'); + + onError(errorMessage); + setOauthLoading(false); + } + }; + + const providerConfig: Record = { + google: { label: 'Google', file: 'google.svg' }, + github: { label: 'GitHub', file: 'github.svg' }, + keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, + azure: { label: 'Microsoft', file: 'microsoft.svg' }, + apple: { label: 'Apple', file: 'apple.svg' }, + oidc: { label: 'OpenID', file: 'oidc.svg' }, + }; + + if (providers.length === 0) { + return null; + } + + return ( +
+ {providers + .filter((providerId) => providerId in providerConfig) + .map((providerId) => { + const provider = providerConfig[providerId]; + return ( + + ); + })} + {oauthLoading && ( +

+ {t('setup.login.oauthPending', 'Opening browser for authentication...')} +

+ )} +
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx deleted file mode 100644 index 4ad388822f..0000000000 --- a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState } from 'react'; -import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { authService } from '@app/services/authService'; -import { STIRLING_SAAS_URL } from '@app/constants/connection'; -import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml'; -import { BASE_PATH } from '@app/constants/app'; - -interface LoginFormProps { - serverUrl: string; - isSaaS?: boolean; - onLogin: (username: string, password: string) => Promise; - loading: boolean; -} - -export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, onLogin, loading }) => { - const { t } = useTranslation(); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [validationError, setValidationError] = useState(null); - const [oauthLoading, setOauthLoading] = useState(false); - const [showInstructions, setShowInstructions] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Validation - if (!username.trim()) { - setValidationError(isSaaS - ? t('setup.login.error.emptyEmail', 'Please enter your email') - : t('setup.login.error.emptyUsername', 'Please enter your username')); - return; - } - - if (!password) { - setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password')); - return; - } - - setValidationError(null); - await onLogin(username.trim(), password); - }; - - const handleOAuthLogin = async (provider: 'google' | 'github') => { - // Prevent concurrent OAuth attempts - if (oauthLoading || loading) { - return; - } - - try { - setOauthLoading(true); - setValidationError(null); - - // For SaaS, use configured SaaS URL; for self-hosted, derive from serverUrl - const authServerUrl = isSaaS - ? STIRLING_SAAS_URL - : serverUrl; // Self-hosted might have its own auth - - // Build callback page HTML with translations and dark mode support - const successHtml = buildOAuthCallbackHtml({ - title: t('oauth.success.title', 'Authentication Successful'), - message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'), - isError: false, - }); - - const errorHtml = buildOAuthCallbackHtml({ - title: t('oauth.error.title', 'Authentication Failed'), - message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'), - isError: true, - errorPlaceholder: true, // {error} will be replaced by Rust - }); - - const userInfo = await authService.loginWithOAuth(provider, authServerUrl, successHtml, errorHtml); - - // Call the onLogin callback to complete setup (username/password not needed for OAuth) - await onLogin(userInfo.username, ''); - } catch (error) { - console.error('OAuth login failed:', error); - - const errorMessage = error instanceof Error - ? error.message - : t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.'); - - setValidationError(errorMessage); - setOauthLoading(false); - } - }; - - return ( -
- - - {t('setup.login.connectingTo', 'Connecting to:')} {isSaaS ? 'stirling.com' : serverUrl} - - - {/* Login requirement note for self-hosted servers */} - {!isSaaS && ( - - - {t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '} - setShowInstructions(!showInstructions)} - style={{ cursor: 'pointer' }} - > - {showInstructions - ? t('setup.login.hideInstructions', 'Hide instructions') - : t('setup.login.showInstructions', 'How to enable?')} - - - - - - - {t('setup.login.instructions', 'To enable login on your Stirling PDF server:')} - - - {t('setup.login.instructionsEnvVar', 'Set the environment variable:')} - - - SECURITY_ENABLELOGIN=true - - - {t('setup.login.instructionsOrYml', 'Or in settings.yml:')} - - - security.enableLogin: true - - - {t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')} - - - - - )} - - {/* OAuth Login Buttons - Only show for SaaS */} - {isSaaS && ( - <> - - - - - - - - {oauthLoading && ( - - {t('setup.login.oauthPending', 'Opening browser for authentication...')} - - )} - - - - - )} - - { - setUsername(e.target.value); - setValidationError(null); - }} - disabled={loading} - required - /> - - { - setPassword(e.target.value); - setValidationError(null); - }} - disabled={loading} - required - /> - - {validationError && ( - - {validationError} - - )} - - - -
- ); -}; diff --git a/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx b/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx deleted file mode 100644 index 8242fa5ed0..0000000000 --- a/frontend/src/desktop/components/SetupWizard/ModeSelection.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { Stack, Button, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import CloudIcon from '@mui/icons-material/Cloud'; -import ComputerIcon from '@mui/icons-material/Computer'; - -interface ModeSelectionProps { - onSelect: (mode: 'saas' | 'selfhosted') => void; - loading: boolean; -} - -export const ModeSelection: React.FC = ({ onSelect, loading }) => { - const { t } = useTranslation(); - - return ( - - - - - - ); -}; diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx new file mode 100644 index 0000000000..ab82bde1ed --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginHeader from '@app/routes/login/LoginHeader'; +import ErrorMessage from '@app/routes/login/ErrorMessage'; +import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'; +import DividerWithText from '@app/components/shared/DividerWithText'; +import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons'; +import { SelfHostedLink } from '@app/components/SetupWizard/SelfHostedLink'; +import { UserInfo } from '@app/services/authService'; +import '@app/routes/authShared/auth.css'; + +interface SaaSLoginScreenProps { + serverUrl: string; + onLogin: (username: string, password: string) => Promise; + onOAuthSuccess: (userInfo: UserInfo) => Promise; + onSelfHostedClick: () => void; + loading: boolean; + error: string | null; +} + +export const SaaSLoginScreen: React.FC = ({ + serverUrl, + onLogin, + onOAuthSuccess, + onSelfHostedClick, + loading, + error, +}) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleEmailPasswordSubmit = async () => { + // Validation + if (!email.trim()) { + setValidationError(t('setup.login.error.emptyEmail', 'Please enter your email')); + return; + } + + if (!password) { + setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password')); + return; + } + + setValidationError(null); + await onLogin(email.trim(), password); + }; + + const handleOAuthError = (errorMessage: string) => { + setValidationError(errorMessage); + }; + + const displayError = error || validationError; + + return ( + <> + + + + + + + + + { + setEmail(value); + setValidationError(null); + }} + setPassword={(value) => { + setPassword(value); + setValidationError(null); + }} + onSubmit={handleEmailPasswordSubmit} + isSubmitting={loading} + submitButtonText={t('setup.login.submit', 'Login')} + /> + + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/SelfHostedLink.tsx b/frontend/src/desktop/components/SetupWizard/SelfHostedLink.tsx new file mode 100644 index 0000000000..6a184cc584 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SelfHostedLink.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import '@app/routes/authShared/auth.css'; + +interface SelfHostedLinkProps { + onClick: () => void; + disabled?: boolean; +} + +export const SelfHostedLink: React.FC = ({ onClick, disabled = false }) => { + const { t } = useTranslation(); + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx new file mode 100644 index 0000000000..9a68b91561 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Text } from '@mantine/core'; +import LoginHeader from '@app/routes/login/LoginHeader'; +import ErrorMessage from '@app/routes/login/ErrorMessage'; +import EmailPasswordForm from '@app/routes/login/EmailPasswordForm'; +import DividerWithText from '@app/components/shared/DividerWithText'; +import { DesktopOAuthButtons, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons'; +import { UserInfo } from '@app/services/authService'; +import '@app/routes/authShared/auth.css'; + +interface SelfHostedLoginScreenProps { + serverUrl: string; + enabledOAuthProviders?: string[]; + onLogin: (username: string, password: string) => Promise; + onOAuthSuccess: (userInfo: UserInfo) => Promise; + loading: boolean; + error: string | null; +} + +export const SelfHostedLoginScreen: React.FC = ({ + serverUrl, + enabledOAuthProviders, + onLogin, + onOAuthSuccess, + loading, + error, +}) => { + const { t } = useTranslation(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = async () => { + // Validation + if (!username.trim()) { + setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username')); + return; + } + + if (!password) { + setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password')); + return; + } + + setValidationError(null); + await onLogin(username.trim(), password); + }; + + const handleOAuthError = (errorMessage: string) => { + setValidationError(errorMessage); + }; + + const displayError = error || validationError; + + return ( + <> + + + + + + {t('setup.login.connectingTo', 'Connecting to:')} {serverUrl} + + + {/* Show OAuth buttons if providers are available */} + {enabledOAuthProviders && enabledOAuthProviders.length > 0 && ( + <> + + + + + )} + + { + setUsername(value); + setValidationError(null); + }} + setPassword={(value) => { + setPassword(value); + setValidationError(null); + }} + onSubmit={handleSubmit} + isSubmitting={loading} + submitButtonText={t('setup.login.submit', 'Login')} + /> + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx index 3ca5ea65b2..f8a0d4f9e1 100644 --- a/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; -import { Stack, Button, TextInput } from '@mantine/core'; +import { Stack, Button, TextInput, Alert, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { ServerConfig } from '@app/services/connectionModeService'; import { connectionModeService } from '@app/services/connectionModeService'; +import LocalIcon from '@app/components/shared/LocalIcon'; interface ServerSelectionProps { onSelect: (config: ServerConfig) => void; @@ -14,11 +15,13 @@ export const ServerSelection: React.FC = ({ onSelect, load const [customUrl, setCustomUrl] = useState(''); const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); + const [securityDisabled, setSecurityDisabled] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const url = customUrl.trim(); + // Normalize URL: trim and remove trailing slashes + const url = customUrl.trim().replace(/\/+$/, ''); if (!url) { setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL')); @@ -28,6 +31,7 @@ export const ServerSelection: React.FC = ({ onSelect, load // Test connection before proceeding setTesting(true); setTestError(null); + setSecurityDisabled(false); try { const isReachable = await connectionModeService.testConnection(url); @@ -38,9 +42,67 @@ export const ServerSelection: React.FC = ({ onSelect, load return; } - // Connection successful + // Fetch OAuth providers and check if login is enabled + let enabledProviders: string[] = []; + try { + const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`); + + // Check if security is disabled (status 403 or error response) + if (!response.ok) { + if (response.status === 403 || response.status === 401) { + setSecurityDisabled(true); + setTesting(false); + return; + } + // Other error statuses - show generic error + setTestError( + t('setup.server.error.configFetch', 'Failed to fetch server configuration (status {{status}})', { + status: response.status + }) + ); + setTesting(false); + return; + } + + const data = await response.json(); + console.log('Login UI data:', data); + + // Check if the response indicates security is disabled + if (data.enableLogin === false || data.securityEnabled === false) { + setSecurityDisabled(true); + setTesting(false); + return; + } + + // Extract provider IDs from authorization URLs + // Example: "/oauth2/authorization/google" → "google" + enabledProviders = Object.keys(data.providerList || {}) + .map(key => key.split('/').pop()) + .filter((id): id is string => id !== undefined); + + console.log('[ServerSelection] Detected OAuth providers:', enabledProviders); + } catch (err) { + console.error('[ServerSelection] Failed to fetch login configuration', err); + + // Check if it's a security disabled error + if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) { + setSecurityDisabled(true); + setTesting(false); + return; + } + + // For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed + setTestError( + t('setup.server.error.configFetch', 'Failed to fetch server configuration. Please check the URL and try again.') + ); + setTesting(false); + return; + } + + // Connection successful - pass URL and OAuth providers onSelect({ url, + enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined, }); } catch (error) { console.error('Connection test failed:', error); @@ -64,6 +126,7 @@ export const ServerSelection: React.FC = ({ onSelect, load onChange={(e) => { setCustomUrl(e.target.value); setTestError(null); + setSecurityDisabled(false); }} disabled={loading || testing} error={testError} @@ -73,6 +136,28 @@ export const ServerSelection: React.FC = ({ onSelect, load )} /> + {securityDisabled && ( + } + title={t('setup.server.error.securityDisabled.title', 'Login Not Enabled')} + > + + + {t('setup.server.error.securityDisabled.body', 'This server does not have login enabled. To connect to this server, you must enable authentication:')} + + +
    +
  1. {t('setup.server.error.securityDisabled.step1', 'Set DOCKER_ENABLE_SECURITY=true in your environment')}
  2. +
  3. {t('setup.server.error.securityDisabled.step2', 'Or set security.enableLogin=true in settings.yml')}
  4. +
  5. {t('setup.server.error.securityDisabled.step3', 'Restart the server')}
  6. +
+
+
+
+ )} + - )} - - - - + {/* Back Button */} + {activeStep > SetupStep.SaaSLogin && !loading && ( +
+ +
+ )} + ); }; diff --git a/frontend/src/desktop/services/apiClient.ts b/frontend/src/desktop/services/apiClient.ts index 8773afc5e4..257099c801 100644 --- a/frontend/src/desktop/services/apiClient.ts +++ b/frontend/src/desktop/services/apiClient.ts @@ -14,7 +14,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig'; const apiClient = create({ baseURL: getApiBaseUrl(), responseType: 'json', - withCredentials: true, + withCredentials: false, // Desktop doesn't need credentials }); // Setup interceptors (desktop-specific auth and backend ready checks) diff --git a/frontend/src/desktop/services/apiClientSetup.ts b/frontend/src/desktop/services/apiClientSetup.ts index d01c0d9973..ee9cbcf55f 100644 --- a/frontend/src/desktop/services/apiClientSetup.ts +++ b/frontend/src/desktop/services/apiClientSetup.ts @@ -48,13 +48,21 @@ export function setupApiInterceptors(client: AxiosInstance): void { // Debug logging console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`); - // Add auth token for remote requests + // Add auth token for remote requests and enable credentials const isRemote = await operationRouter.isSelfHostedMode(); if (isRemote) { + // Self-hosted mode: enable credentials for session management + extendedConfig.withCredentials = true; + const token = await authService.getAuthToken(); if (token) { extendedConfig.headers.Authorization = `Bearer ${token}`; + } else { + console.warn('[apiClientSetup] Self-hosted mode but no auth token available'); } + } else { + // SaaS mode: disable credentials (security disabled on local backend) + extendedConfig.withCredentials = false; } // Backend readiness check (for local backend) @@ -85,7 +93,9 @@ export function setupApiInterceptors(client: AxiosInstance): void { // Response interceptor: Handle auth errors client.interceptors.response.use( - (response) => response, + (response) => { + return response; + }, async (error) => { const originalRequest = error.config as ExtendedRequestConfig; diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 76f8aa1577..ed8be911ff 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -25,6 +25,7 @@ export class AuthService { private static instance: AuthService; private authStatus: AuthStatus = 'unauthenticated'; private userInfo: UserInfo | null = null; + private cachedToken: string | null = null; private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>(); static getInstance(): AuthService { @@ -38,13 +39,32 @@ export class AuthService { * Save token to all storage locations and notify listeners */ private async saveTokenEverywhere(token: string): Promise { - // Save to Tauri store - await invoke('save_auth_token', { token }); - console.log('[Desktop AuthService] Token saved to Tauri store'); + // Validate token before caching + if (!token || token.trim().length === 0) { + console.warn('[Desktop AuthService] Attempted to save invalid/empty token'); + throw new Error('Invalid token'); + } - // Sync to localStorage for web layer - localStorage.setItem('stirling_jwt', token); - console.log('[Desktop AuthService] Token saved to localStorage'); + try { + // Save to Tauri store + await invoke('save_auth_token', { token }); + console.log('[Desktop AuthService] ✅ Token saved to Tauri store'); + } catch (error) { + console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error); + // Don't throw - we can still use localStorage + } + + try { + // Sync to localStorage for web layer + localStorage.setItem('stirling_jwt', token); + console.log('[Desktop AuthService] ✅ Token saved to localStorage'); + } catch (error) { + console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error); + } + + // Cache the valid token in memory + this.cachedToken = token; + console.log('[Desktop AuthService] ✅ Token cached in memory'); // Notify other parts of the system window.dispatchEvent(new CustomEvent('jwt-available')); @@ -56,20 +76,25 @@ export class AuthService { */ private async getTokenFromAnySource(): Promise { // Try Tauri store first - console.log('[Desktop AuthService] Retrieving token from Tauri store...'); - const token = await invoke('get_auth_token'); + try { + const token = await invoke('get_auth_token'); - if (token) { - console.log(`[Desktop AuthService] Token found in Tauri store (length: ${token.length})`); - return token; + if (token) { + console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`); + return token; + } + + console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...'); + } catch (error) { + console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error); } - console.log('[Desktop AuthService] No token in Tauri store'); - // Fallback to localStorage const localStorageToken = localStorage.getItem('stirling_jwt'); if (localStorageToken) { - console.log('[Desktop AuthService] Token found in localStorage (length:', localStorageToken.length, ')'); + console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`); + } else { + console.log('[Desktop AuthService] ❌ No token found in any storage'); } return localStorageToken; @@ -79,6 +104,10 @@ export class AuthService { * Clear token from all storage locations */ private async clearTokenEverywhere(): Promise { + // Invalidate cache + this.cachedToken = null; + console.log('[Desktop AuthService] Cache invalidated'); + await invoke('clear_auth_token'); localStorage.removeItem('stirling_jwt'); } @@ -183,7 +212,22 @@ export class AuthService { async getAuthToken(): Promise { try { - return await this.getTokenFromAnySource(); + // Return cached token if available + if (this.cachedToken) { + console.debug('[Desktop AuthService] ✅ Returning cached token'); + return this.cachedToken; + } + + console.debug('[Desktop AuthService] Cache miss, fetching from storage...'); + const token = await this.getTokenFromAnySource(); + + // Cache the token if valid + if (token && token.trim().length > 0) { + this.cachedToken = token; + console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval'); + } + + return token; } catch (error) { console.error('[Desktop AuthService] Failed to get auth token:', error); return null; diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index f01dbc40a6..cf454a50b3 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -5,6 +5,7 @@ export type ConnectionMode = 'saas' | 'selfhosted'; export interface ServerConfig { url: string; + enabledOAuthProviders?: string[]; } export interface ConnectionConfig { diff --git a/frontend/src/desktop/services/tauriHttpClient.ts b/frontend/src/desktop/services/tauriHttpClient.ts index 89c87bfbb1..b5bbceb930 100644 --- a/frontend/src/desktop/services/tauriHttpClient.ts +++ b/frontend/src/desktop/services/tauriHttpClient.ts @@ -61,7 +61,7 @@ class TauriHttpClient { headers: {}, timeout: 120000, responseType: 'json', - withCredentials: true, + withCredentials: false, // Desktop doesn't need credentials (backend has allowCredentials=false) }; public interceptors: Interceptors = { @@ -173,14 +173,15 @@ class TauriHttpClient { } try { - // Debug logging - console.debug(`[tauriHttpClient] Fetch request:`, { url, method }); + // Convert withCredentials to fetch API's credentials option + const credentials: RequestCredentials = finalConfig.withCredentials ? 'include' : 'omit'; // Make the request using Tauri's native HTTP client (standard Fetch API) const response = await fetch(url, { method, headers, body, + credentials, }); // Parse response based on responseType diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index 8934f4c457..19721b7600 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -13,7 +13,6 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann interface SecuritySettingsData { enableLogin?: boolean; - csrfDisabled?: boolean; loginMethod?: string; loginAttemptCount?: number; loginResetTimeMinutes?: number; @@ -123,7 +122,6 @@ export default function AdminSecuritySection() { const deltaSettings: Record = { // Security settings 'security.enableLogin': securitySettings.enableLogin, - 'security.csrfDisabled': securitySettings.csrfDisabled, 'security.loginMethod': securitySettings.loginMethod, 'security.loginAttemptCount': securitySettings.loginAttemptCount, 'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes, @@ -282,23 +280,6 @@ export default function AdminSecuritySection() { disabled={!loginEnabled} /> - -
-
- {t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')} - - {t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')} - -
- - setSettings({ ...settings, csrfDisabled: e.target.checked })} - disabled={!loginEnabled} - /> - - -
From e7db714091af8ac62bf47e99047bca7b2247994f Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 4 Dec 2025 17:53:08 +0000 Subject: [PATCH 08/15] More fixes for automate (#5168) # Description of Changes Fix file missed in #5127 to use `apiClient` instead of `axios` directly Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- frontend/src/core/utils/automationFileProcessor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/core/utils/automationFileProcessor.ts b/frontend/src/core/utils/automationFileProcessor.ts index b07fe961be..311848bfd6 100644 --- a/frontend/src/core/utils/automationFileProcessor.ts +++ b/frontend/src/core/utils/automationFileProcessor.ts @@ -2,7 +2,7 @@ * File processing utilities specifically for automation workflows */ -import axios from 'axios'; +import apiClient from '@app/services/apiClient'; import { zipFileService } from '@app/services/zipFileService'; import { ResourceManager } from '@app/utils/resourceManager'; import { AUTOMATION_CONSTANTS } from '@app/constants/automation'; @@ -97,7 +97,7 @@ export class AutomationFileProcessor { options: AutomationProcessingOptions = {} ): Promise { try { - const response = await axios.post(endpoint, formData, { + const response = await apiClient.post(endpoint, formData, { responseType: options.responseType || 'blob', timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); @@ -139,7 +139,7 @@ export class AutomationFileProcessor { options: AutomationProcessingOptions = {} ): Promise { try { - const response = await axios.post(endpoint, formData, { + const response = await apiClient.post(endpoint, formData, { responseType: options.responseType || 'blob', timeout: options.timeout || AUTOMATION_CONSTANTS.OPERATION_TIMEOUT }); From 3a2370ea1f2feb9887e17afb2fced4acc198102c Mon Sep 17 00:00:00 2001 From: Keon Chen <66115421+keonchennl@users.noreply.github.com> Date: Thu, 4 Dec 2025 22:35:11 +0100 Subject: [PATCH 09/15] Update OCR setup guide link in LanguagePicker (#5162) # Description of Changes --- ## 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/devGuide/DeveloperGuide.md) (if applicable) - [ x] 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 - [x ] 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) - [x ] 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) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src/core/components/tools/ocr/LanguagePicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/components/tools/ocr/LanguagePicker.tsx b/frontend/src/core/components/tools/ocr/LanguagePicker.tsx index 784d22da5c..7e023e9ccd 100644 --- a/frontend/src/core/components/tools/ocr/LanguagePicker.tsx +++ b/frontend/src/core/components/tools/ocr/LanguagePicker.tsx @@ -134,7 +134,7 @@ const LanguagePicker: React.FC = ({ textDecoration: 'underline', textAlign: 'center' }} - onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')} + onClick={() => window.open('https://docs.stirlingpdf.com/Configuration/OCR', '_blank')} > {t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')} @@ -158,4 +158,4 @@ const LanguagePicker: React.FC = ({ ); }; -export default LanguagePicker; \ No newline at end of file +export default LanguagePicker; From 9fd8fd89ed26de1147e4ed21a585390658b0cfe4 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 5 Dec 2025 13:18:23 +0000 Subject: [PATCH 10/15] add enum SERVER to list of valid licenses (#5172) --- frontend/src/core/hooks/useServerExperience.ts | 2 +- frontend/src/proprietary/contexts/ServerExperienceContext.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts index 8dd60069a7..28f62c1c57 100644 --- a/frontend/src/core/hooks/useServerExperience.ts +++ b/frontend/src/core/hooks/useServerExperience.ts @@ -65,7 +65,7 @@ export function useServerExperience(): ServerExperienceValue { const loginEnabled = config?.enableLogin !== false; const configIsAdmin = Boolean(config?.isAdmin); const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); - const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE'; + const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE'; const setSelfReportedAdmin = useCallback((value: boolean) => { setSelfReportedAdminState(value); diff --git a/frontend/src/proprietary/contexts/ServerExperienceContext.tsx b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx index 17ac572fc3..92ce8148a4 100644 --- a/frontend/src/proprietary/contexts/ServerExperienceContext.tsx +++ b/frontend/src/proprietary/contexts/ServerExperienceContext.tsx @@ -249,7 +249,7 @@ export function ServerExperienceProvider({ children }: { children: ReactNode }) }, [fetchUserCounts]); const hasPaidLicense = useMemo(() => { - return config?.license === 'PRO' || config?.license === 'ENTERPRISE'; + return config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE'; }, [config?.license]); const licenseKeyValid = useMemo(() => { From 82dbcfbb9b78a01dee5a2754391a5444172b8875 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 5 Dec 2025 23:19:41 +0000 Subject: [PATCH 11/15] SSO login fix (#5167) Fixes bug where SSO login with custom providers caused an `InvalidClientRegistrationIdException: Invalid Client Registration with Id: oidc` errors. Root Cause: - Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc` regardless of provider registration ID - Frontend: Unknown providers were mapped back to 'oidc' instead of using actual provider ID Closes #5141 --------- Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com> Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com> --- ...tomOAuth2AuthenticationSuccessHandler.java | 8 + .../security/oauth2/OAuth2Configuration.java | 24 +- ...stomSaml2AuthenticationSuccessHandler.java | 6 + .../service/UserLicenseSettingsService.java | 71 ++++- .../oauth2/OAuth2ConfigurationTest.java | 162 ++++++++++ .../UserLicenseSettingsServiceTest.java | 218 +++++++++++++ frontend/src/proprietary/auth/oauthTypes.ts | 24 ++ .../src/proprietary/auth/springAuthClient.ts | 8 +- .../src/proprietary/routes/Login.test.tsx | 178 +++++++++-- frontend/src/proprietary/routes/Login.tsx | 21 +- .../routes/login/OAuthButtons.test.tsx | 291 ++++++++++++++++++ .../proprietary/routes/login/OAuthButtons.tsx | 5 +- 12 files changed, 961 insertions(+), 55 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationTest.java create mode 100644 frontend/src/proprietary/auth/oauthTypes.ts create mode 100644 frontend/src/proprietary/routes/login/OAuthButtons.test.tsx diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index e1e6703945..793c6b62fa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; @@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; +@Slf4j @RequiredArgsConstructor public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler if (user != null && !licenseSettingsService.isOAuthEligible(user)) { // User is not grandfathered and no paid license - block OAuth login + log.warn( + "OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)", + username); response.sendRedirect( request.getContextPath() + "/logout?oAuth2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isOAuthEligible(null)) { // No existing user and no paid license -> block auto creation + log.warn( + "OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)", + username); response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true"); return; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index a053c1ead2..2d5f94620a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -67,10 +67,15 @@ public class OAuth2Configuration { keycloakClientRegistration().ifPresent(registrations::add); if (registrations.isEmpty()) { - log.error("No OAuth2 provider registered"); + log.error("No OAuth2 provider registered - check your OAuth2 configuration"); throw new NoProviderFoundException("At least one OAuth2 provider must be configured."); } + log.info( + "OAuth2 ClientRegistrationRepository created with {} provider(s): {}", + registrations.size(), + registrations.stream().map(ClientRegistration::getRegistrationId).toList()); + return new InMemoryClientRegistrationRepository(registrations); } @@ -165,7 +170,6 @@ public class OAuth2Configuration { githubClient.getUseAsUsername()); boolean isValid = validateProvider(github); - log.info("Initialised GitHub OAuth2 provider"); return isValid ? Optional.of( @@ -208,7 +212,19 @@ public class OAuth2Configuration { null, null); - return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider) + boolean isValid = + !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider); + if (isValid) { + log.info( + "Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'", + name, + oauth.getIssuer(), + REDIRECT_URI_PATH + name); + } else { + log.warn("OIDC OAuth2 provider validation failed - provider will not be registered"); + } + + return isValid ? Optional.of( ClientRegistrations.fromIssuerLocation(oauth.getIssuer()) .registrationId(name) @@ -217,7 +233,7 @@ public class OAuth2Configuration { .scope(oidcProvider.getScopes()) .userNameAttributeName(oidcProvider.getUseAsUsername().getName()) .clientName(clientName) - .redirectUri(REDIRECT_URI_PATH + "oidc") + .redirectUri(REDIRECT_URI_PATH + name) .authorizationGrantType(AUTHORIZATION_CODE) .build()) : Optional.empty(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index b342fdcb46..e8bce579a0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -74,12 +74,18 @@ public class CustomSaml2AuthenticationSuccessHandler if (user != null && !licenseSettingsService.isSamlEligible(user)) { // User is not grandfathered and no ENTERPRISE license - block SAML login + log.warn( + "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", + username); response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { // No existing user and no ENTERPRISE license -> block auto creation + log.warn( + "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", + username); response.sendRedirect( request.getContextPath() + "/logout?saml2RequiresLicense=true"); return; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index d3bade89c0..aa794e6997 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.model.UserLicenseSettings; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository; import stirling.software.proprietary.security.service.UserService; @@ -331,28 +332,45 @@ public class UserLicenseSettingsService { } /** - * Checks if a user is eligible to use OAuth authentication. + * Checks if a user is eligible to use OAuth/SAML authentication. * *

A user is eligible if: * *

    *
  • They are grandfathered for OAuth (existing user before policy change), OR - *
  • The system has a paid license (SERVER or ENTERPRISE) + *
  • The system has an ENTERPRISE license (SSO is enterprise-only) *
* * @param user The user to check - * @return true if the user can use OAuth + * @return true if the user can use OAuth/SAML */ - public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) { + public boolean isOAuthEligible(User user) { + String username = (user != null) ? user.getUsername() : ""; + log.info("OAuth eligibility check for user: {}", username); + // Grandfathered users always have OAuth access if (user != null && user.isOauthGrandfathered()) { log.debug("User {} is grandfathered for OAuth", user.getUsername()); return true; } + // todo: remove + if (user != null) { + log.info( + "User {} is NOT grandfathered (isOauthGrandfathered={})", + username, + user.isOauthGrandfathered()); + } else { + log.info("New user attempting OAuth login - checking license requirement"); + } + // Users can use OAuth with SERVER or ENTERPRISE license boolean hasPaid = hasPaidLicense(); - log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaid); + log.info( + "OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}", + hasPaid, + username, + hasPaid); return hasPaid; } @@ -369,16 +387,32 @@ public class UserLicenseSettingsService { * @param user The user to check * @return true if the user can use SAML */ - public boolean isSamlEligible(stirling.software.proprietary.security.model.User user) { + public boolean isSamlEligible(User user) { + String username = (user != null) ? user.getUsername() : ""; + log.info("SAML2 eligibility check for user: {}", username); + // Grandfathered users always have SAML access if (user != null && user.isOauthGrandfathered()) { - log.debug("User {} is grandfathered for SAML", user.getUsername()); + log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username); return true; } + if (user != null) { + log.info( + "User {} is NOT grandfathered (isOauthGrandfathered={})", + username, + user.isOauthGrandfathered()); + } else { + log.info("New user attempting SAML2 login - checking license requirement"); + } + // Users can use SAML only with ENTERPRISE license boolean hasEnterprise = hasEnterpriseLicense(); - log.debug("SAML eligibility check: hasEnterpriseLicense={}", hasEnterprise); + log.info( + "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}", + hasEnterprise, + username, + hasEnterprise); return hasEnterprise; } @@ -521,12 +555,17 @@ public class UserLicenseSettingsService { if (checker == null) { return false; } + License license = checker.getPremiumLicenseEnabledResult(); - return license == License.SERVER || license == License.ENTERPRISE; + boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE); + log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid); + + return hasPaid; } /** - * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SAML. + * Checks if the system has an ENTERPRISE license. Used for enterprise-only features like SSO + * (OAuth/SAML). * * @return true if ENTERPRISE license is active */ @@ -535,7 +574,19 @@ public class UserLicenseSettingsService { if (checker == null) { return false; } + License license = checker.getPremiumLicenseEnabledResult(); + log.info( + "License check result: type={}, requiresEnterprise=true, hasEnterprise={}", + license, + (license == License.ENTERPRISE)); + + if (license != License.ENTERPRISE) { + log.warn( + "SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.", + license); + } + return license == License.ENTERPRISE; } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationTest.java new file mode 100644 index 0000000000..750696b770 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationTest.java @@ -0,0 +1,162 @@ +package stirling.software.proprietary.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for OAuth2Configuration redirect URI logic. + * + *

These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment + * MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing + * InvalidClientRegistrationIdException when custom provider names were used. + * + *

Note: These are conceptual tests documenting the expected behavior. Full integration testing + * with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2. + * Valid OIDC configuration responses 3. Network mocking infrastructure + */ +class OAuth2ConfigurationTest { + + /** + * Tests the redirect URI pattern for OIDC provider configurations. + * + *

Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the + * registration ID. For example: - Provider name: "authentik" → Redirect URI: + * "/login/oauth2/code/authentik" - Provider name: "mycompany" → Redirect URI: + * "/login/oauth2/code/mycompany" - Provider name: "oidc" → Redirect URI: + * "/login/oauth2/code/oidc" + * + *

Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for + * a registration with ID 'oidc' when the provider redirected back. This caused + * InvalidClientRegistrationIdException when custom provider names were used. + */ + @Test + void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() { + // Verify the redirect URI pattern constant + String redirectUriBase = "{baseUrl}/login/oauth2/code/"; + + // Test cases: provider name → expected redirect URI + String[][] testCases = { + {"authentik", redirectUriBase + "authentik"}, + {"mycompany", redirectUriBase + "mycompany"}, + {"oidc", redirectUriBase + "oidc"}, + {"okta", redirectUriBase + "okta"}, + {"auth0", redirectUriBase + "auth0"} + }; + + for (String[] testCase : testCases) { + String providerName = testCase[0]; + String expectedRedirectUri = testCase[1]; + + // The fix ensures: .redirectUri(REDIRECT_URI_PATH + name) + // instead of: .redirectUri(REDIRECT_URI_PATH + "oidc") + String actualRedirectUri = redirectUriBase + providerName; + + assertEquals( + expectedRedirectUri, + actualRedirectUri, + String.format( + "Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'", + providerName)); + } + } + + /** + * Documents the critical fix for OAuth2 redirect URI mismatch. + * + *

This test validates the logic that was changed in OAuth2Configuration.java line 220: + * + *

+     * // BEFORE (bug):
+     * .redirectUri(REDIRECT_URI_PATH + "oidc")  // Always "oidc"
+     *
+     * // AFTER (fix):
+     * .redirectUri(REDIRECT_URI_PATH + name)  // Dynamic provider name
+     * 
+ */ + @Test + void testCriticalFix_redirectUriMatchesRegistrationId() { + // The redirect URI path segment extraction by Spring Security + String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123"; + + // Spring extracts the path segment between "code/" and "?" + String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl); + + // The extracted ID MUST match an actual registration ID + assertEquals("authentik", extractedRegistrationId); + + // If we had used hardcoded "oidc", the callback would be: + String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123"; + String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl); + + // This would look for registration with ID "oidc" but we registered "authentik" + assertEquals("oidc", buggyExtractedId); + + // The mismatch: registrationId="authentik", but Spring looks for "oidc" + // Result: InvalidClientRegistrationIdException + assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed"); + } + + /** Helper method simulating Spring's extraction of registration ID from callback URL */ + private String extractRegistrationIdFromCallback(String callbackUrl) { + // Simplified version of what Spring Security does + // Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path + String path = callbackUrl.split("\\?")[0]; + String[] parts = path.split("/"); + return parts[parts.length - 1]; // Last path segment + } + + /** + * Validates the frontend-backend flow for custom provider names. + * + *

Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend: + * ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to + * "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with + * "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for + * OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring + * Security redirects to provider with redirect_uri containing "authentik" 8. Provider: + * Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security + * extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID + * "authentik" ✅ SUCCESS + * + *

If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to + * "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc" + * Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result: + * InvalidClientRegistrationIdException + */ + @Test + void testEndToEndFlow_registrationIdConsistency() { + String providerName = "authentik"; + + // Step 2: Registration ID + String registrationId = providerName; + assertEquals("authentik", registrationId); + + // Step 3: Redirect URI (MUST use same name) + String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName; + assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri); + + // Step 4: Provider list endpoint + String authorizationPath = "/oauth2/authorization/" + providerName; + assertEquals("/oauth2/authorization/authentik", authorizationPath); + + // Step 5: Frontend extracts provider ID + String frontendProviderId = + authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1); + assertEquals("authentik", frontendProviderId); + + // Step 6-8: OAuth flow (external) + + // Step 9: Callback URL from provider + String callbackUrl = + "http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123"; + String extractedId = extractRegistrationIdFromCallback(callbackUrl); + + // Step 10: Registration lookup + assertEquals( + registrationId, + extractedId, + "Registration ID from callback MUST match original registration ID"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java index 139146d707..7f9445ad7c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -267,4 +267,222 @@ class UserLicenseSettingsServiceTest { verify(userService, times(1)).grandfatherAllOAuthUsers(); verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession(); } + + // ===== OAuth Eligibility Tests ===== + + @Test + void isOAuthEligible_grandfatheredUser_returnsTrue() { + // Grandfathered user should be eligible regardless of license + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("grandfathered-user"); + user.setOauthGrandfathered(true); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + boolean result = service.isOAuthEligible(user); + + assertEquals(true, result, "Grandfathered user should be eligible for OAuth"); + } + + @Test + void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() { + // Non-grandfathered user with SERVER license should be eligible + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + boolean result = service.isOAuthEligible(user); + + assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible"); + } + + @Test + void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() { + // Non-grandfathered user with ENTERPRISE license should be eligible + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + boolean result = service.isOAuthEligible(user); + + assertEquals( + true, result, "Non-grandfathered user with ENTERPRISE license should be eligible"); + } + + @Test + void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() { + // Non-grandfathered user without license should NOT be eligible + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + boolean result = service.isOAuthEligible(user); + + assertEquals( + false, + result, + "Non-grandfathered user without paid license should NOT be eligible"); + } + + @Test + void isOAuthEligible_newUserWithServerLicense_returnsTrue() { + // New user (null) with SERVER license should be eligible for auto-creation + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + boolean result = service.isOAuthEligible(null); + + assertEquals( + true, result, "New user with SERVER license should be eligible for auto-creation"); + } + + @Test + void isOAuthEligible_newUserWithNoLicense_returnsFalse() { + // New user (null) without license should NOT be eligible + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + boolean result = service.isOAuthEligible(null); + + assertEquals( + false, + result, + "New user without paid license should NOT be eligible for auto-creation"); + } + + @Test + void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() { + // If LicenseKeyChecker is unavailable, OAuth should be blocked + when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null); + + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + boolean result = service.isOAuthEligible(user); + + assertEquals( + false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable"); + } + + // ===== SAML Eligibility Tests ===== + + @Test + void isSamlEligible_grandfatheredUser_returnsTrue() { + // Grandfathered user should be eligible for SAML regardless of license + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("grandfathered-user"); + user.setOauthGrandfathered(true); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + boolean result = service.isSamlEligible(user); + + assertEquals(true, result, "Grandfathered user should be eligible for SAML"); + } + + @Test + void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() { + // Non-grandfathered user with ENTERPRISE license should be eligible + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + boolean result = service.isSamlEligible(user); + + assertEquals( + true, + result, + "Non-grandfathered user with ENTERPRISE license should be eligible for SAML"); + } + + @Test + void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() { + // Non-grandfathered user with SERVER license should NOT be eligible for SAML + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + boolean result = service.isSamlEligible(user); + + assertEquals( + false, + result, + "Non-grandfathered user with SERVER license should NOT be eligible for SAML"); + } + + @Test + void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() { + // Non-grandfathered user without license should NOT be eligible + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + boolean result = service.isSamlEligible(user); + + assertEquals( + false, + result, + "Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML"); + } + + @Test + void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() { + // New user (null) with ENTERPRISE license should be eligible for auto-creation + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + boolean result = service.isSamlEligible(null); + + assertEquals( + true, + result, + "New user with ENTERPRISE license should be eligible for SAML auto-creation"); + } + + @Test + void isSamlEligible_newUserWithServerLicense_returnsFalse() { + // New user (null) with SERVER license should NOT be eligible for SAML + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + boolean result = service.isSamlEligible(null); + + assertEquals( + false, + result, + "New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)"); + } + + @Test + void isSamlEligible_licenseCheckerUnavailable_returnsFalse() { + // If LicenseKeyChecker is unavailable, SAML should be blocked + when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null); + + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("test-user"); + user.setOauthGrandfathered(false); + + boolean result = service.isSamlEligible(user); + + assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable"); + } } diff --git a/frontend/src/proprietary/auth/oauthTypes.ts b/frontend/src/proprietary/auth/oauthTypes.ts new file mode 100644 index 0000000000..2d38f1b3e5 --- /dev/null +++ b/frontend/src/proprietary/auth/oauthTypes.ts @@ -0,0 +1,24 @@ +/** + * Known OAuth providers with dedicated UI support. + * Custom providers are also supported - the backend determines availability. + */ +export const KNOWN_OAUTH_PROVIDERS = [ + 'github', + 'google', + 'apple', + 'azure', + 'keycloak', + 'cloudron', + 'authentik', + 'oidc', +] as const; + +export type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number]; + +/** + * OAuth provider ID - can be any known provider or custom string. + * The backend configuration determines which providers are available. + * + * @example 'github' | 'google' | 'mycompany' | 'authentik' + */ +export type OAuthProvider = KnownOAuthProvider | (string & {}); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 2f1aa36cb5..646b711823 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -10,6 +10,7 @@ import apiClient from '@app/services/apiClient'; import { AxiosError } from 'axios'; import { BASE_PATH } from '@app/constants/app'; +import { type OAuthProvider } from '@app/auth/oauthTypes'; // Helper to extract error message from axios error function getErrorMessage(error: unknown, fallback: string): string { @@ -248,11 +249,14 @@ class SpringAuthClient { } /** - * Sign in with OAuth provider (GitHub, Google, etc.) + * Sign in with OAuth provider (GitHub, Google, Authentik, etc.) * This redirects to the Spring OAuth2 authorization endpoint + * + * @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany') + * Can be any known provider or custom string - the backend determines available providers */ async signInWithOAuth(params: { - provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc'; + provider: OAuthProvider; options?: { redirectTo?: string; queryParams?: Record }; }): Promise<{ error: AuthError | null }> { try { diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 62679f22ac..996176c01c 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -7,6 +7,7 @@ import Login from '@app/routes/Login'; import { useAuth } from '@app/auth/UseSession'; import { springAuth } from '@app/auth/springAuthClient'; import { PreferencesProvider } from '@app/contexts/PreferencesContext'; +import apiClient from '@app/services/apiClient'; // Mock i18n to return fallback text vi.mock('react-i18next', () => ({ @@ -36,8 +37,13 @@ vi.mock('@app/hooks/useDocumentMeta', () => ({ useDocumentMeta: vi.fn(), })); -// Mock fetch for provider list -global.fetch = vi.fn(); +// Mock apiClient for provider list +vi.mock('@app/services/apiClient', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})); const mockNavigate = vi.fn(); const mockBackendProbeState = { @@ -89,14 +95,13 @@ describe('Login', () => { refreshSession: vi.fn(), }); - // Mock fetch for login UI data - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ + // Mock apiClient for login UI data + vi.mocked(apiClient.get).mockResolvedValue({ + data: { enableLogin: true, providerList: {}, - }), - } as Response); + }, + }); }); it('should render login form', async () => { @@ -239,6 +244,136 @@ describe('Login', () => { }); }); + it('should use actual provider ID for OAuth login (authentik)', async () => { + const user = userEvent.setup(); + + // Mock provider list with authentik + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + enableLogin: true, + providerList: { + '/oauth2/authorization/authentik': 'Authentik', + }, + }, + }); + + vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ + error: null, + }); + + render( + + + + + + ); + + // Wait for OAuth button to appear + await waitFor(() => { + const button = screen.queryByText('Authentik'); + expect(button).toBeTruthy(); + }, { timeout: 3000 }); + + const oauthButton = screen.getByText('Authentik'); + await user.click(oauthButton); + + await waitFor(() => { + // Should use 'authentik' directly, NOT map to 'oidc' + expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'authentik', + options: { redirectTo: '/auth/callback' } + }); + }); + }); + + it('should use actual provider ID for OAuth login (custom provider)', async () => { + const user = userEvent.setup(); + + // Mock provider list with custom provider 'mycompany' + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + enableLogin: true, + providerList: { + '/oauth2/authorization/mycompany': 'My Company SSO', + }, + }, + }); + + vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ + error: null, + }); + + render( + + + + + + ); + + // Wait for OAuth button to appear (will show 'Mycompany' as label) + await waitFor(() => { + const button = screen.queryByText('Mycompany'); + expect(button).toBeTruthy(); + }, { timeout: 3000 }); + + const oauthButton = screen.getByText('Mycompany'); + await user.click(oauthButton); + + await waitFor(() => { + // Should use 'mycompany' directly - this is the critical fix + // Previously it would map unknown providers to 'oidc' + expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'mycompany', + options: { redirectTo: '/auth/callback' } + }); + }); + }); + + it('should use oidc provider ID when explicitly configured', async () => { + const user = userEvent.setup(); + + // Mock provider list with 'oidc' + vi.mocked(apiClient.get).mockResolvedValue({ + data: { + enableLogin: true, + providerList: { + '/oauth2/authorization/oidc': 'OIDC', + }, + }, + }); + + vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ + error: null, + }); + + render( + + + + + + ); + + // Wait for OAuth button to appear + await waitFor(() => { + const button = screen.queryByText('OIDC'); + expect(button).toBeTruthy(); + }, { timeout: 3000 }); + + const oauthButton = screen.getByText('OIDC'); + await user.click(oauthButton); + + await waitFor(() => { + // Should use 'oidc' when explicitly configured + expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'oidc', + options: { redirectTo: '/auth/callback' } + }); + }); + }); + it('should show error on failed login', async () => { const user = userEvent.setup(); const errorMessage = 'Invalid credentials'; @@ -359,13 +494,12 @@ describe('Login', () => { it('should redirect to home when login disabled', async () => { mockBackendProbeState.loginDisabled = true; mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false }); - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { enableLogin: false, providerList: {}, - }), - } as Response); + }, + }); render( @@ -381,15 +515,14 @@ describe('Login', () => { }); it('should handle OAuth provider click', async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { enableLogin: true, providerList: { '/oauth2/authorization/github': 'GitHub', }, - }), - } as Response); + }, + }); vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({ error: null, @@ -416,13 +549,12 @@ describe('Login', () => { }); it('should show email form by default when no SSO providers', async () => { - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { enableLogin: true, providerList: {}, // No providers - }), - } as Response); + }, + }); render( diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 80a12e1d09..cf6004e505 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -10,6 +10,7 @@ import AuthLayout from '@app/routes/authShared/AuthLayout'; import { useBackendProbe } from '@app/hooks/useBackendProbe'; import apiClient from '@app/services/apiClient'; import { BASE_PATH } from '@app/constants/app'; +import { type OAuthProvider } from '@app/auth/oauthTypes'; // Import login components import LoginHeader from '@app/routes/login/LoginHeader'; @@ -31,7 +32,7 @@ export default function Login() { const [showEmailForm, setShowEmailForm] = useState(false); const [email, setEmail] = useState(() => searchParams.get('email') ?? ''); const [password, setPassword] = useState(''); - const [enabledProviders, setEnabledProviders] = useState([]); + const [enabledProviders, setEnabledProviders] = useState([]); const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); const backendProbe = useBackendProbe(); @@ -226,25 +227,17 @@ export default function Login() { ); } - // Known OAuth providers that have dedicated backend support - const KNOWN_OAUTH_PROVIDERS = ['github', 'google', 'apple', 'azure', 'keycloak', 'oidc'] as const; - type KnownOAuthProvider = typeof KNOWN_OAUTH_PROVIDERS[number]; - - const signInWithProvider = async (provider: string) => { + const signInWithProvider = async (provider: OAuthProvider) => { try { setIsSigningIn(true); setError(null); - // Map unknown providers to 'oidc' for the backend redirect - const backendProvider: KnownOAuthProvider = KNOWN_OAUTH_PROVIDERS.includes(provider as KnownOAuthProvider) - ? (provider as KnownOAuthProvider) - : 'oidc'; + console.log(`[Login] Signing in with provider: ${provider}`); - console.log(`[Login] Signing in with ${provider} (backend: ${backendProvider})`); - - // Redirect to Spring OAuth2 endpoint + // Redirect to Spring OAuth2 endpoint using the actual provider ID from backend + // The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak') const { error } = await springAuth.signInWithOAuth({ - provider: backendProvider, + provider: provider, options: { redirectTo: `${BASE_PATH}/auth/callback` } }); diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.test.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.test.tsx new file mode 100644 index 0000000000..62f121d68c --- /dev/null +++ b/frontend/src/proprietary/routes/login/OAuthButtons.test.tsx @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MantineProvider } from '@mantine/core'; +import OAuthButtons from '@app/routes/login/OAuthButtons'; + +// Mock i18n +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback || key, + }), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('OAuthButtons', () => { + const mockOnProviderClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render known providers with correct labels', () => { + const enabledProviders = ['google', 'github', 'authentik']; + + render( + + + + ); + + // Check that known providers are rendered with their labels + expect(screen.getByText('Google')).toBeTruthy(); + expect(screen.getByText('GitHub')).toBeTruthy(); + expect(screen.getByText('Authentik')).toBeTruthy(); + }); + + it('should render unknown provider with capitalized label and generic icon', () => { + const enabledProviders = ['mycompany']; + + render( + + + + ); + + // Unknown provider should be capitalized + expect(screen.getByText('Mycompany')).toBeTruthy(); + + // Check that button has generic OIDC icon + const button = screen.getByText('Mycompany').closest('button'); + expect(button).toBeTruthy(); + const img = button?.querySelector('img'); + expect(img?.src).toContain('oidc.svg'); + }); + + it('should call onProviderClick with actual provider ID (not "oidc")', async () => { + const user = userEvent.setup(); + const enabledProviders = ['mycompany']; + + render( + + + + ); + + const button = screen.getByText('Mycompany'); + await user.click(button); + + // Should use actual provider ID 'mycompany', NOT 'oidc' + expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany'); + }); + + it('should call onProviderClick with "authentik" when authentik is clicked', async () => { + const user = userEvent.setup(); + const enabledProviders = ['authentik']; + + render( + + + + ); + + const button = screen.getByText('Authentik'); + await user.click(button); + + expect(mockOnProviderClick).toHaveBeenCalledWith('authentik'); + }); + + it('should call onProviderClick with "oidc" when OIDC is explicitly configured', async () => { + const user = userEvent.setup(); + const enabledProviders = ['oidc']; + + render( + + + + ); + + const button = screen.getByText('OIDC'); + await user.click(button); + + expect(mockOnProviderClick).toHaveBeenCalledWith('oidc'); + }); + + it('should disable buttons when isSubmitting is true', () => { + const enabledProviders = ['google', 'github']; + + render( + + + + ); + + const googleButton = screen.getByText('Google').closest('button') as HTMLButtonElement; + const githubButton = screen.getByText('GitHub').closest('button') as HTMLButtonElement; + + expect(googleButton.disabled).toBe(true); + expect(githubButton.disabled).toBe(true); + }); + + it('should render nothing when no providers are enabled', () => { + const { container } = render( + + + + ); + + // Should render null/nothing (excluding Mantine's style tags) + const hasContent = Array.from(container.children).some( + child => child.tagName.toLowerCase() !== 'style' + ); + expect(hasContent).toBe(false); + }); + + it('should render multiple unknown providers with correct IDs', async () => { + const user = userEvent.setup(); + const enabledProviders = ['company1', 'company2', 'company3']; + + render( + + + + ); + + // All should be capitalized + expect(screen.getByText('Company1')).toBeTruthy(); + expect(screen.getByText('Company2')).toBeTruthy(); + expect(screen.getByText('Company3')).toBeTruthy(); + + // Click each and verify correct ID is passed + await user.click(screen.getByText('Company1')); + expect(mockOnProviderClick).toHaveBeenCalledWith('company1'); + + await user.click(screen.getByText('Company2')); + expect(mockOnProviderClick).toHaveBeenCalledWith('company2'); + + await user.click(screen.getByText('Company3')); + expect(mockOnProviderClick).toHaveBeenCalledWith('company3'); + }); + + it('should use correct icon for known providers', () => { + const enabledProviders = ['google', 'github', 'authentik', 'keycloak']; + + render( + + + + ); + + // Check that each known provider has its specific icon + const googleButton = screen.getByText('Google').closest('button'); + expect(googleButton?.querySelector('img')?.src).toContain('google.svg'); + + const githubButton = screen.getByText('GitHub').closest('button'); + expect(githubButton?.querySelector('img')?.src).toContain('github.svg'); + + const authentikButton = screen.getByText('Authentik').closest('button'); + expect(authentikButton?.querySelector('img')?.src).toContain('authentik.svg'); + + const keycloakButton = screen.getByText('Keycloak').closest('button'); + expect(keycloakButton?.querySelector('img')?.src).toContain('keycloak.svg'); + }); + + it('should handle mixed known and unknown providers', async () => { + const user = userEvent.setup(); + const enabledProviders = ['google', 'mycompany', 'authentik', 'custom']; + + render( + + + + ); + + // Known providers with correct labels + expect(screen.getByText('Google')).toBeTruthy(); + expect(screen.getByText('Authentik')).toBeTruthy(); + + // Unknown providers with capitalized labels + expect(screen.getByText('Mycompany')).toBeTruthy(); + expect(screen.getByText('Custom')).toBeTruthy(); + + // Click each and verify IDs are preserved + await user.click(screen.getByText('Google')); + expect(mockOnProviderClick).toHaveBeenCalledWith('google'); + + await user.click(screen.getByText('Mycompany')); + expect(mockOnProviderClick).toHaveBeenCalledWith('mycompany'); + + await user.click(screen.getByText('Authentik')); + expect(mockOnProviderClick).toHaveBeenCalledWith('authentik'); + + await user.click(screen.getByText('Custom')); + expect(mockOnProviderClick).toHaveBeenCalledWith('custom'); + }); + + it('should maintain provider ID consistency - critical for OAuth redirect', async () => { + const user = userEvent.setup(); + + // This test ensures the fix for GitHub issue #5141 + // The provider ID used in the button click MUST match the backend registration ID + // Previously, unknown providers were mapped to 'oidc', breaking the OAuth flow + + const enabledProviders = ['authentik', 'okta', 'auth0']; + + render( + + + + ); + + // Each provider should use its actual ID, not 'oidc' + await user.click(screen.getByText('Authentik')); + expect(mockOnProviderClick).toHaveBeenLastCalledWith('authentik'); + + await user.click(screen.getByText('Okta')); + expect(mockOnProviderClick).toHaveBeenLastCalledWith('okta'); + + await user.click(screen.getByText('Auth0')); + expect(mockOnProviderClick).toHaveBeenLastCalledWith('auth0'); + + // Verify none were called with 'oidc' instead of their actual ID + expect(mockOnProviderClick).not.toHaveBeenCalledWith('oidc'); + }); +}); diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index aaa280519f..d62edfdc15 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { BASE_PATH } from '@app/constants/app'; +import { type OAuthProvider } from '@app/auth/oauthTypes'; // Debug flag to show all providers for UI testing // Set to true to see all SSO options regardless of backend configuration @@ -22,10 +23,10 @@ export const oauthProviderConfig: Record void + onProviderClick: (provider: OAuthProvider) => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' - enabledProviders?: string[] // List of enabled provider IDs from backend + enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend } export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) { From bb201ef9c10609398576fb4cc1d2033a9d4034e4 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:22:32 +0000 Subject: [PATCH 12/15] Chore/bump gradle version number (#5176) bump version number --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2ae1355944..85f6a480de 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.0' + version = '2.1.1' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' From 7faf7e50facf487e95b1ae1917b4611a3b30bfe8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 6 Dec 2025 00:06:11 +0000 Subject: [PATCH 13/15] Chang etext on intro (#5160) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/ar-AR/translation.toml | 6 +++--- .../public/locales/az-AZ/translation.toml | 2 +- .../public/locales/bg-BG/translation.toml | 2 +- .../public/locales/ca-CA/translation.toml | 10 +++++----- .../public/locales/cs-CZ/translation.toml | 4 ++-- .../public/locales/da-DK/translation.toml | 8 ++++---- .../public/locales/de-DE/translation.toml | 2 +- .../public/locales/el-GR/translation.toml | 6 +++--- .../public/locales/en-GB/translation.toml | 2 +- .../public/locales/es-ES/translation.toml | 2 +- .../public/locales/eu-ES/translation.toml | 2 +- .../public/locales/fa-IR/translation.toml | 8 ++++---- .../public/locales/fr-FR/translation.toml | 8 ++++---- .../public/locales/ga-IE/translation.toml | 6 +++--- .../public/locales/hi-IN/translation.toml | 20 +++++++++---------- .../public/locales/hr-HR/translation.toml | 2 +- .../public/locales/hu-HU/translation.toml | 2 +- .../public/locales/id-ID/translation.toml | 6 +++--- .../public/locales/it-IT/translation.toml | 4 ++-- .../public/locales/ja-JP/translation.toml | 2 +- .../public/locales/ko-KR/translation.toml | 2 +- .../public/locales/ml-ML/translation.toml | 2 +- .../public/locales/nl-NL/translation.toml | 2 +- .../public/locales/no-NB/translation.toml | 2 +- .../public/locales/pl-PL/translation.toml | 8 ++++---- .../public/locales/pt-BR/translation.toml | 4 ++-- .../public/locales/pt-PT/translation.toml | 2 +- .../public/locales/ro-RO/translation.toml | 8 ++++---- .../public/locales/ru-RU/translation.toml | 2 +- .../public/locales/sk-SK/translation.toml | 2 +- .../public/locales/sl-SI/translation.toml | 2 +- .../locales/sr-LATN-RS/translation.toml | 4 ++-- .../public/locales/sv-SE/translation.toml | 2 +- .../public/locales/th-TH/translation.toml | 6 +++--- .../public/locales/tr-TR/translation.toml | 2 +- .../public/locales/uk-UA/translation.toml | 4 ++-- .../public/locales/vi-VN/translation.toml | 4 ++-- .../public/locales/zh-BO/translation.toml | 4 ++-- .../public/locales/zh-CN/translation.toml | 2 +- .../public/locales/zh-TW/translation.toml | 2 +- .../onboarding/slides/ServerLicenseSlide.tsx | 2 +- 41 files changed, 86 insertions(+), 86 deletions(-) diff --git a/frontend/public/locales/ar-AR/translation.toml b/frontend/public/locales/ar-AR/translation.toml index 4c437dbf45..4376a1dea7 100644 --- a/frontend/public/locales/ar-AR/translation.toml +++ b/frontend/public/locales/ar-AR/translation.toml @@ -3828,8 +3828,8 @@ title = "التحليلات" description = "تساعدنا هذه الملفات على فهم كيفية استخدام أدواتنا، كي نركّز على بناء الميزات الأكثر قيمة لمجتمعنا. كن مطمئنًا—‏Stirling PDF لا يمكنه ولن يتتبع محتوى المستندات التي تعمل عليها." [cookieBanner.services] -posthog = "PostHog Analytics" -scarf = "Scarf Pixel" +posthog = "تحليلات PostHog" +scarf = "Scarf بكسل" [removeMetadata] submit = "إزالة البيانات الوصفية" @@ -5177,7 +5177,7 @@ upgrade = "الترقية الآن →" freeTitle = "ترخيص الخادم" overLimitTitle = "مطلوب ترخيص خادم" overLimitBody = "ترخيصنا يسمح حتى {{freeTierLimit}} مستخدمين مجاناً لكل خادم. لديك {{overLimitUserCopy}} مستخدمي Stirling. للمتابعة دون انقطاع، ارقَ إلى خطة خادم Stirling - مقاعد غير محدودة، تحرير نصوص PDF، وتحكم إداري كامل مقابل $99/خادم/شهرياً." -freeBody = "ترخيص Open-Core لدينا يسمح حتى {{freeTierLimit}} مستخدمين مجاناً لكل خادم. للتوسع بسلاسة والحصول على وصول مبكر إلى أداة تحرير نصوص PDF الجديدة، نوصي بخطة خادم Stirling - تحرير كامل ومقاعد غير محدودة مقابل $99/خادم/شهرياً." +freeBody = "يتيح ترخيصنا Open-Core ما يصل إلى {{freeTierLimit}} مستخدمًا مجانًا لكل خادم. للتوسع دون انقطاع، نوصي بخطة Stirling Server - مقاعد غير محدودة ودعم SSO مقابل $99/server/mo." [onboarding.desktopInstall] title = "تنزيل" diff --git a/frontend/public/locales/az-AZ/translation.toml b/frontend/public/locales/az-AZ/translation.toml index 092b0fda76..fe13e4d0f6 100644 --- a/frontend/public/locales/az-AZ/translation.toml +++ b/frontend/public/locales/az-AZ/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "İndi yüksəlt →" freeTitle = "Server lisenziyası" overLimitTitle = "Server lisenziyası tələb olunur" overLimitBody = "Lisenziyalaşmamız hər server üçün pulsuz olaraq maksimum {{freeTierLimit}} istifadəçiyə icazə verir. Sizdə {{overLimitUserCopy}} Stirling istifadəçisi var. Fasiləsiz davam etmək üçün Stirling Server planına yüksəldin - limitsiz yerlər, PDF mətn redaktəsi və tam admin nəzarəti cəmi $99/server/ay." -freeBody = "Bizim Open-Core lisenziyası hər server üçün pulsuz olaraq maksimum {{freeTierLimit}} istifadəçiyə icazə verir. Fasiləsiz miqyaslanmaq və yeni PDF mətn redaktəsi alətimizə erkən çıxış əldə etmək üçün Stirling Server planını tövsiyə edirik — tam redaktə və limitsiz yerlər $99/server/ay." +freeBody = "Bizim Open-Core lisenziyalaşdırmamız hər server üçün pulsuz olaraq ən çox {{freeTierLimit}} istifadəçiyə icazə verir. Fasiləsiz miqyaslama üçün Stirling Server planını tövsiyə edirik - limitsiz yerlərSSO dəstəyi $99/server/ay." [onboarding.desktopInstall] title = "Yüklə" diff --git a/frontend/public/locales/bg-BG/translation.toml b/frontend/public/locales/bg-BG/translation.toml index 13ae297672..adc099082e 100644 --- a/frontend/public/locales/bg-BG/translation.toml +++ b/frontend/public/locales/bg-BG/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Надградете сега →" freeTitle = "Лиценз за сървър" overLimitTitle = "Необходим е лиценз за сървър" overLimitBody = "Нашият лиценз позволява до {{freeTierLimit}} безплатни потребители на сървър. Имате {{overLimitUserCopy}} потребители на Stirling. За да продължите без прекъсвания, надградете до плана Stirling Server – неограничени места, редакция на PDF текст и пълен админ контрол за $99/сървър/месец." -freeBody = "Нашият Open-Core лиценз позволява до {{freeTierLimit}} безплатни потребители на сървър. За да мащабирате без прекъсвания и да получите ранен достъп до нашия нов инструмент за редакция на PDF текст, препоръчваме плана Stirling Server – пълно редактиране и неограничени места за $99/сървър/месец." +freeBody = "Нашият лицензен модел Open-Core позволява до {{freeTierLimit}} потребители безплатно на сървър. За безпрепятствено мащабиране препоръчваме плана Stirling Server - неограничени места и поддръжка на SSO за $99/server/mo." [onboarding.desktopInstall] title = "Изтегляне" diff --git a/frontend/public/locales/ca-CA/translation.toml b/frontend/public/locales/ca-CA/translation.toml index 85090e5a56..d521141f40 100644 --- a/frontend/public/locales/ca-CA/translation.toml +++ b/frontend/public/locales/ca-CA/translation.toml @@ -352,7 +352,7 @@ teams = "Equips" title = "Configuració" systemSettings = "Configuració del sistema" features = "Funcions" -endpoints = "Endpoints" +endpoints = "Punts finals" database = "Base de dades" advanced = "Avançat" @@ -561,7 +561,7 @@ totalEndpoints = "Total d'endpoints" totalVisits = "Total de visites" showing = "Mostrant" selectedVisits = "Visites seleccionades" -endpoint = "Endpoint" +endpoint = "Punt final" visits = "Visites" percentage = "Percentatge" loading = "Carregant..." @@ -4366,7 +4366,7 @@ features = "Banderes de funcions" processing = "Processament" [admin.settings.advanced.endpoints] -label = "Endpoints" +label = "Punts finals" manage = "Gestiona els endpoints de l'API" description = "La gestió d'endpoints es configura via YAML. Consulteu la documentació per a detalls sobre com habilitar/deshabilitar endpoints específics." @@ -5177,7 +5177,7 @@ upgrade = "Actualitza ara →" freeTitle = "Llicència del servidor" overLimitTitle = "Cal una llicència de servidor" overLimitBody = "La nostra llicència permet fins a {{freeTierLimit}} usuaris gratuïts per servidor. Tens {{overLimitUserCopy}} usuaris de Stirling. Per continuar sense interrupcions, actualitza al pla Stirling Server: seients il·limitats, edició de text de PDF i control d'administració complet per 99 $/servidor/mes." -freeBody = "La nostra llicència Open-Core permet fins a {{freeTierLimit}} usuaris gratuïts per servidor. Per escalar sense interrupcions i obtenir accés anticipat a la nova eina d'edició de text PDF, recomanem el pla Stirling Server: edició completa i seients il·limitats per 99 $/servidor/mes." +freeBody = "La nostra llicència Open-Core permet fins a {{freeTierLimit}} usuaris gratuïts per servidor. Per escalar sense interrupcions, recomanem el pla Stirling Server - places il·limitades i suport SSO per $99/servidor/mes." [onboarding.desktopInstall] title = "Baixa" @@ -5754,7 +5754,7 @@ title = "Gràfic d'ús dels endpoints" [usage.table] title = "Estadístiques detallades" -endpoint = "Endpoint" +endpoint = "Punt final" visits = "Visites" percentage = "Percentatge" noData = "No hi ha dades disponibles" diff --git a/frontend/public/locales/cs-CZ/translation.toml b/frontend/public/locales/cs-CZ/translation.toml index d1ffd5b7f8..4d580e79a8 100644 --- a/frontend/public/locales/cs-CZ/translation.toml +++ b/frontend/public/locales/cs-CZ/translation.toml @@ -4176,7 +4176,7 @@ description = "Sledovat akce uživatelů a systémové události pro compliance [admin.settings.security.audit.level] label = "Úroveň auditu" -description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE" +description = "0=VYPNUTO, 1=ZÁKLADNÍ, 2=STANDARDNÍ, 3=PODROBNÝ" [admin.settings.security.audit.retentionDays] label = "Doba uchování auditů (dny)" @@ -5177,7 +5177,7 @@ upgrade = "Upgradovat nyní →" freeTitle = "Serverová licence" overLimitTitle = "Vyžadována serverová licence" overLimitBody = "Naše licencování umožňuje až {{freeTierLimit}} uživatelů zdarma na server. Máte {{overLimitUserCopy}} uživatelů Stirling. Pro nepřerušené používání přejděte na plán Stirling Server – neomezený počet míst, úpravy textu PDF a plná správa za 99 $/server/měsíc." -freeBody = "Naše licencování Open-Core umožňuje až {{freeTierLimit}} uživatelů zdarma na server. Pro nepřerušený růst a přednostní přístup k našemu novému nástroji pro úpravu textu PDF doporučujeme plán Stirling Server – plné úpravy a neomezený počet míst za 99 $/server/měsíc." +freeBody = "Naše licencování Open-Core umožňuje až {{freeTierLimit}} uživatelů zdarma na server. Pro nepřerušované škálování doporučujeme plán Stirling Server - neomezený počet míst a podpora SSO za $99/server/měs." [onboarding.desktopInstall] title = "Stáhnout" diff --git a/frontend/public/locales/da-DK/translation.toml b/frontend/public/locales/da-DK/translation.toml index d3a6cca28d..9ed522f5a7 100644 --- a/frontend/public/locales/da-DK/translation.toml +++ b/frontend/public/locales/da-DK/translation.toml @@ -1221,9 +1221,9 @@ pdfaDigitalSignatureWarning = "PDF'en indeholder en digital signatur. Dette vil fileFormat = "Filformat" wordDoc = "Word-dokument" wordDocExt = "Word-dokument (.docx)" -odtExt = "OpenDocument Text (.odt)" +odtExt = "OpenDocument-tekst (.odt)" pptExt = "PowerPoint (.pptx)" -odpExt = "OpenDocument Presentation (.odp)" +odpExt = "OpenDocument-præsentation (.odp)" txtExt = "Almindelig tekst (.txt)" rtfExt = "Rich Text Format (.rtf)" selectedFiles = "Valgte filer" @@ -3790,7 +3790,7 @@ version = "Nuværende udgivelse" title = "API-dokumentation" header = "API-dokumentation" desc = "Se og test Stirling PDF API-endpoints" -tags = "api,documentation,swagger,endpoints,development" +tags = "api,dokumentation,swagger,endepunkter,udvikling" [cookieBanner.popUp] title = "Sådan bruger vi cookies" @@ -5177,7 +5177,7 @@ upgrade = "Opgrader nu →" freeTitle = "Serverlicens" overLimitTitle = "Serverlicens påkrævet" overLimitBody = "Vores licens tillader op til {{freeTierLimit}} brugere gratis pr. server. Du har {{overLimitUserCopy}} Stirling-brugere. For at fortsætte uden afbrydelser skal du opgradere til Stirling Server-abonnementet – ubegrænsede pladser, PDF-tekstredigering og fuld admin-kontrol for $99/server/md." -freeBody = "Vores Open-Core-licens tillader op til {{freeTierLimit}} brugere gratis pr. server. For at skalere uden afbrydelser og få tidlig adgang til vores nye PDF-tekstredigeringsværktøj anbefaler vi Stirling Server-planen – fuld redigering og ubegrænsede pladser for $99/server/md." +freeBody = "Vores Open-Core-licens tillader op til {{freeTierLimit}} brugere gratis pr. server. For at skalere uden afbrydelser anbefaler vi Stirling Server-planen – ubegrænsede pladser og SSO-understøttelse for $99/server/md." [onboarding.desktopInstall] title = "Download" diff --git a/frontend/public/locales/de-DE/translation.toml b/frontend/public/locales/de-DE/translation.toml index 70dcf86b20..29dc3bf9fa 100644 --- a/frontend/public/locales/de-DE/translation.toml +++ b/frontend/public/locales/de-DE/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Jetzt upgraden →" freeTitle = "Server-Lizenz" overLimitTitle = "Server-Lizenz erforderlich" overLimitBody = "Unsere Lizenz erlaubt bis zu {{freeTierLimit}} Nutzer pro Server kostenlos. Sie haben {{overLimitUserCopy}} Stirling-Nutzer. Um ohne Unterbrechung fortzufahren, upgraden Sie auf den Stirling-Server-Plan – unbegrenzte Plätze, PDF-Textbearbeitung und volle Admin-Kontrolle für $99/Server/Monat." -freeBody = "Unsere Open-Core-Lizenz erlaubt bis zu {{freeTierLimit}} Nutzer pro Server kostenlos. Für unterbrechungsfreies Skalieren und frühen Zugriff auf unser neues PDF-Textbearbeitungs-Tool empfehlen wir den Stirling-Server-Plan – volle Bearbeitung und unbegrenzte Plätze für $99/Server/Monat." +freeBody = "Unsere Open-Core-Lizenz erlaubt bis zu {{freeTierLimit}} Nutzern pro Server kostenlos. Um unterbrechungsfrei zu skalieren, empfehlen wir den Stirling Server-Plan - unbegrenzte Plätze und SSO-Unterstützung für $99/Server/Monat." [onboarding.desktopInstall] title = "Download" diff --git a/frontend/public/locales/el-GR/translation.toml b/frontend/public/locales/el-GR/translation.toml index d3cf4c860b..5b47558c7d 100644 --- a/frontend/public/locales/el-GR/translation.toml +++ b/frontend/public/locales/el-GR/translation.toml @@ -3790,7 +3790,7 @@ version = "Τρέχουσα έκδοση" title = "Τεκμηρίωση API" header = "Τεκμηρίωση API" desc = "Προβάλετε και δοκιμάστε τα endpoints του Stirling PDF API" -tags = "api,documentation,swagger,endpoints,development" +tags = "api,τεκμηρίωση,swagger,τελικά σημεία,ανάπτυξη" [cookieBanner.popUp] title = "Πώς χρησιμοποιούμε τα cookies" @@ -4482,7 +4482,7 @@ label = "Ενεργοποίηση προσκλήσεων μέσω email" description = "Να επιτρέπεται στους διαχειριστές να προσκαλούν χρήστες μέσω email με αυτόματα παραγόμενους κωδικούς" [admin.settings.mail.frontendUrl] -label = "Frontend URL" +label = "URL front-end" description = "Βασικό URL για το frontend (π.χ. https://pdf.example.com). Χρησιμοποιείται για τη δημιουργία συνδέσμων πρόσκλησης στα email. Αφήστε κενό για χρήση του backend URL." [admin.settings.legal] @@ -5177,7 +5177,7 @@ upgrade = "Αναβάθμιση τώρα →" freeTitle = "Άδεια διακομιστή" overLimitTitle = "Απαιτείται άδεια διακομιστή" overLimitBody = "Η αδειοδότηση μας επιτρέπει έως {{freeTierLimit}} χρήστες δωρεάν ανά διακομιστή. Έχετε {{overLimitUserCopy}} χρήστες Stirling. Για να συνεχίσετε χωρίς διακοπές, αναβαθμίστε στο πλάνο Stirling Server - απεριόριστες θέσεις, επεξεργασία κειμένου PDF και πλήρης έλεγχος διαχειριστή για $99/server/μήνα." -freeBody = "Η αδειοδότηση Open-Core μας επιτρέπει έως {{freeTierLimit}} χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση και έγκαιρη πρόσβαση στο νέο εργαλείο επεξεργασίας κειμένου PDF, προτείνουμε το πλάνο Stirling Server - πλήρης επεξεργασία και απεριόριστες θέσεις για $99/server/μήνα." +freeBody = "Οι άδειες χρήσης Open-Core επιτρέπουν έως και {{freeTierLimit}} χρήστες δωρεάν ανά διακομιστή. Για απρόσκοπτη κλιμάκωση, προτείνουμε το πλάνο Stirling Server - απεριόριστες θέσεις και υποστήριξη SSO με $99/διακομιστή/μήνα." [onboarding.desktopInstall] title = "Λήψη" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index c60df3a850..fcdcf592f2 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5263,7 +5263,7 @@ upgrade = "Upgrade now →" freeTitle = "Server License" overLimitTitle = "Server License Needed" overLimitBody = "Our licensing permits up to {{freeTierLimit}} users for free per server. You have {{overLimitUserCopy}} Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - unlimited seats, PDF text editing, and full admin control for $99/server/mo." -freeBody = "Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted and get early access to our new PDF text editing tool, we recommend the Stirling Server plan - full editing and unlimited seats for $99/server/mo." +freeBody = "Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - unlimited seats and SSO support for $99/server/mo." [onboarding.desktopInstall] title = "Download" diff --git a/frontend/public/locales/es-ES/translation.toml b/frontend/public/locales/es-ES/translation.toml index 437c0ac2c4..fb0969a1a3 100644 --- a/frontend/public/locales/es-ES/translation.toml +++ b/frontend/public/locales/es-ES/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Actualizar ahora →" freeTitle = "Licencia del servidor" overLimitTitle = "Se necesita licencia de servidor" overLimitBody = "Nuestra licencia permite hasta {{freeTierLimit}} usuarios gratis por servidor. Tiene {{overLimitUserCopy}} usuarios de Stirling. Para continuar sin interrupciones, actualice al plan Stirling Server: plazas ilimitadas, edición de texto PDF y control total de administración por 99 $/servidor/mes." -freeBody = "Nuestra licencia Open-Core permite hasta {{freeTierLimit}} usuarios gratis por servidor. Para escalar sin interrupciones y obtener acceso anticipado a nuestra nueva herramienta de edición de texto PDF, recomendamos el plan Stirling Server: edición completa y plazas ilimitadas por 99 $/servidor/mes." +freeBody = "Nuestra licencia Open-Core permite hasta {{freeTierLimit}} usuarios gratis por servidor. Para escalar sin interrupciones, recomendamos el plan Stirling Server - plazas ilimitadas y soporte SSO por $99/servidor/mes." [onboarding.desktopInstall] title = "Descargar" diff --git a/frontend/public/locales/eu-ES/translation.toml b/frontend/public/locales/eu-ES/translation.toml index 3fc897147c..96dfbfcdcf 100644 --- a/frontend/public/locales/eu-ES/translation.toml +++ b/frontend/public/locales/eu-ES/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Eguneratu orain →" freeTitle = "Zerbitzari-lizentzia" overLimitTitle = "Beharrezkoa da zerbitzari-lizentzia" overLimitBody = "Gure lizentziak baimentzen ditu {{freeTierLimit}} erabiltzaile doan zerbitzari bakoitzeko. {{overLimitUserCopy}} Stirling erabiltzaile dituzu. Jarraitzeko etenik gabe, eguneratu Stirling Server planera - eserleku mugagabeak, PDF testu-edizioa, eta admin kontrol osoa $99/zerbitzari/hilean." -freeBody = "Gure Open-Core lizentziak {{freeTierLimit}} erabiltzaile arte baimentzen ditu doan zerbitzari bakoitzeko. Etenik gabe eskalatzeko eta gure PDF testu-edizio tresna berrirako sarbide goiztiarra lortzeko, gomendatzen dugu Stirling Server plana - edizio osoa eta eserleku mugagabeak $99/zerbitzari/hilean." +freeBody = "Gure Open-Core lizentziak zerbitzari bakoitzeko doan gehienez {{freeTierLimit}} erabiltzaile baimentzen ditu. Etenik gabe eskalatzeko, Stirling Server plana gomendatzen dugu - eserleku mugagabeak eta SSO euskarria $99/server/mo." [onboarding.desktopInstall] title = "Deskargatu" diff --git a/frontend/public/locales/fa-IR/translation.toml b/frontend/public/locales/fa-IR/translation.toml index 8102a9c7c3..26bebba53a 100644 --- a/frontend/public/locales/fa-IR/translation.toml +++ b/frontend/public/locales/fa-IR/translation.toml @@ -4258,11 +4258,11 @@ label = "URL صادرکننده" description = "Issuer URL ارائه‌دهنده OAuth2" [admin.settings.connections.oauth2.clientId] -label = "Client ID" +label = "شناسهٔ کلاینت" description = "Client ID مربوط به OAuth2 از ارائه‌دهنده شما" [admin.settings.connections.oauth2.clientSecret] -label = "Client Secret" +label = "راز کلاینت" description = "Client Secret مربوط به OAuth2 از ارائه‌دهنده شما" [admin.settings.connections.oauth2.useAsUsername] @@ -4293,7 +4293,7 @@ label = "ارائه‌دهنده" description = "نام ارائه‌دهنده SAML2" [admin.settings.connections.saml2.registrationId] -label = "Registration ID" +label = "شناسهٔ ثبت‌نام" description = "شناسه ثبت‌نام SAML2" [admin.settings.connections.saml2.autoCreateUser] @@ -5177,7 +5177,7 @@ upgrade = "همین حالا ارتقا بده →" freeTitle = "لایسنس سرور" overLimitTitle = "نیاز به لایسنس سرور" overLimitBody = "مجوز ما تا {{freeTierLimit}} کاربر رایگان به‌ازای هر سرور را مجاز می‌داند. شما {{overLimitUserCopy}} کاربر Stirling دارید. برای ادامه بدون وقفه، به پلن Stirling Server ارتقا دهید - صندلی نامحدود، ویرایش متن PDF و کنترل کامل ادمین با 99$ به‌ازای هر سرور در ماه." -freeBody = "مجوز Open-Core ما تا {{freeTierLimit}} کاربر رایگان به‌ازای هر سرور را مجاز می‌داند. برای مقیاس‌پذیری بدون وقفه و دسترسی زودهنگام به ابزار ویرایش متن PDF جدیدمان، پلن Stirling Server را پیشنهاد می‌کنیم - ویرایش کامل و صندلی نامحدود با 99$ به‌ازای هر سرور در ماه." +freeBody = "مجوز Open-Core ما به‌ازای هر سرور اجازهٔ استفادهٔ رایگان برای حداکثر {{freeTierLimit}} کاربر را می‌دهد. برای مقیاس‌دهی بدون وقفه، طرح Stirling Server را توصیه می‌کنیم - تعداد کاربران نامحدود و پشتیبانی از SSO با $99/سرور/ماه." [onboarding.desktopInstall] title = "دانلود" diff --git a/frontend/public/locales/fr-FR/translation.toml b/frontend/public/locales/fr-FR/translation.toml index 5ae44d3f7b..1f413e9270 100644 --- a/frontend/public/locales/fr-FR/translation.toml +++ b/frontend/public/locales/fr-FR/translation.toml @@ -363,7 +363,7 @@ connections = "Connexions" [settings.licensingAnalytics] title = "Licences et analyses" -plan = "Plan" +plan = "Forfait" audit = "Audit" usageAnalytics = "Analyses d'utilisation" @@ -4550,7 +4550,7 @@ successMessage = "Fichier de licence téléversé et activé avec succès. Aucun title = "Licence active" file = "Source: Fichier de licence ({{path}})" key = "Source: Clé de licence" -type = "Type: {{type}}" +type = "Type : {{type}}" noInput = "Veuillez fournir une clé de licence ou téléverser un fichier de certificat" success = "Succès" @@ -5177,7 +5177,7 @@ upgrade = "Mettre à niveau maintenant →" freeTitle = "Licence serveur" overLimitTitle = "Licence serveur requise" overLimitBody = "Notre licence autorise jusqu’à {{freeTierLimit}} utilisateurs gratuits par serveur. Vous avez {{overLimitUserCopy}} utilisateurs Stirling. Pour continuer sans interruption, passez au plan Stirling Server — places illimitées, édition de texte PDF et contrôle d’administration complet pour 99 $/serveur/mois." -freeBody = "Notre licence Open-Core autorise jusqu’à {{freeTierLimit}} utilisateurs gratuits par serveur. Pour évoluer sans interruption et accéder en avant-première à notre nouvel outil d’édition de texte PDF, nous recommandons le plan Stirling Server — édition complète et places illimitées pour 99 $/serveur/mois." +freeBody = "Notre régime de licence Open-Core autorise jusqu'à {{freeTierLimit}} utilisateurs gratuitement par serveur. Pour évoluer sans interruption, nous recommandons le forfait Stirling Server - places illimitées et prise en charge du SSO pour 99 $/serveur/mois." [onboarding.desktopInstall] title = "Télécharger" @@ -5892,7 +5892,7 @@ paragraph = "Page de paragraphe" sparse = "Texte clairsemé" [pdfTextEditor.groupingMode] -auto = "Auto" +auto = "Automatique" paragraph = "Paragraphe" singleLine = "Ligne unique" diff --git a/frontend/public/locales/ga-IE/translation.toml b/frontend/public/locales/ga-IE/translation.toml index 254c8578a9..77d27e093a 100644 --- a/frontend/public/locales/ga-IE/translation.toml +++ b/frontend/public/locales/ga-IE/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Uasghrádaigh anois →" freeTitle = "Ceadúnas Freastalaí" overLimitTitle = "Ceadúnas Freastalaí de dhíth" overLimitBody = "Ceadaíonn ár gceadúnú suas le {{freeTierLimit}} úsáideoir in aisce in aghaidh freastalaí. Tá {{overLimitUserCopy}} úsáideoir Stirling agat. Chun leanúint gan bhriseadh, uasghrádaigh go plean Freastalaí Stirling - suíocháin neamhtheoranta, eagarthóireacht téacs PDF, agus lánrialú riaracháin ar $99/freastalaí/mí." -freeBody = "Ceadaíonn ár gceadúnú Open-Core suas le {{freeTierLimit}} úsáideoir in aisce in aghaidh freastalaí. Chun méadú gan bhriseadh agus rochtain luath a fháil ar ár uirlis eagarthóireachta téacs PDF nua, molaimid Plean Freastalaí Stirling - eagarthóireacht iomlán agus suíocháin neamhtheoranta ar $99/freastalaí/mí." +freeBody = "Ceadaíonn ár gceadúnú Open-Core suas le {{freeTierLimit}} úsáideoirí saor in aisce in aghaidh an fhreastalaí. Chun scálú gan bhriseadh, molaimid an plean Stirling Server - suíocháin neamhtheoranta agus tacaíocht SSO ar $99/server/mo." [onboarding.desktopInstall] title = "Íoslódáil" @@ -5333,8 +5333,8 @@ emailDisabled = "Teastaíonn cumraíocht SMTP agus mail.enableInvites=true sna s [workspace.people.license] users = "úsáideoirí" availableSlots = "Áiteanna Ar Fáil" -grandfathered = "Grandfathered" -grandfatheredShort = "{{count}} grandfathered" +grandfathered = "Ceadaithe roimhe seo" +grandfatheredShort = "{{count}} ceadaithe roimhe seo" fromLicense = "ón gceadúnas" slotsAvailable = "{{count}} áit(í) úsáideora ar fáil" noSlotsAvailable = "Níl aon áiteanna ar fáil" diff --git a/frontend/public/locales/hi-IN/translation.toml b/frontend/public/locales/hi-IN/translation.toml index c569e48ba8..011479c9f1 100644 --- a/frontend/public/locales/hi-IN/translation.toml +++ b/frontend/public/locales/hi-IN/translation.toml @@ -834,7 +834,7 @@ title = "PDF हस्ताक्षर सत्यापित करें" desc = "PDF दस्तावेजों में डिजिटल हस्ताक्षर और प्रमाणपत्रों को सत्यापित करें" [home.swagger] -tags = "API,documentation,test" +tags = "API,दस्तावेज़ीकरण,परीक्षण" title = "API दस्तावेज़ीकरण" desc = "API दस्तावेज़ देखें और एंडपॉइंट टेस्ट करें" @@ -883,7 +883,7 @@ title = "रंग बदलें/उलटें" desc = "PDF दस्तावेज़ों में रंगों को प्रतिस्थापित या उलटें" [home.devApi] -tags = "API,development,documentation" +tags = "API,विकास,दस्तावेज़ीकरण" title = "API" desc = "API दस्तावेज़ के लिए लिंक" @@ -922,7 +922,7 @@ title = "PDF टेक्स्ट एडिटर" desc = "PDF फ़ाइलों के भीतर मौजूदा टेक्स्ट और इमेज संपादित करें" [home.addText] -tags = "text,annotation,label" +tags = "पाठ,टिप्पणी,लेबल" title = "टेक्स्ट जोड़ें" desc = "अपने PDF में कहीं भी कस्टम टेक्स्ट जोड़ें" @@ -1840,7 +1840,7 @@ title = "उन्नत" tags = "कम्प्रेस,छोटा,छोटा" [unlockPDFForms] -tags = "remove,delete,form,field,readonly" +tags = "हटाएं,मिटाएं,फॉर्म,फ़ील्ड,रीड-ओनली" title = "फॉर्म फ़ील्ड से Read-Only हटाएं" header = "PDF फॉर्म अनलॉक करें" submit = "Remove" @@ -2747,7 +2747,7 @@ submit = "जमा करें" failed = "मल्टी-पृष्ठ लेआउट बनाते समय त्रुटि हुई।" [bookletImposition] -tags = "booklet,imposition,printing,binding,folding,signature" +tags = "बुकलेट,इम्पोज़िशन,प्रिंटिंग,बाइंडिंग,फोल्डिंग,सिग्नेचर" title = "बुकलेट इम्पोज़िशन" header = "बुकलेट इम्पोज़िशन" submit = "बुकलेट बनाएँ" @@ -2846,7 +2846,7 @@ scaleFactor = "एक पृष्ठ का ज़ूम स्तर (क् submit = "जमा करें" [adjustPageScale] -tags = "resize,modify,dimension,adapt" +tags = "आकार बदलें,संशोधित करें,आयाम,अनुकूलित करें" title = "पृष्ठ स्केल समायोजित करें" header = "पृष्ठ स्केल समायोजित करें" submit = "पृष्ठ स्केल समायोजित करें" @@ -3396,7 +3396,7 @@ certHint = "कस्टम ट्रस्ट स्रोत के विर title = "सत्यापन सेटिंग्स" [replaceColor] -tags = "Replace Colour,Page operations,Back end,server side" +tags = "रंग बदलें,पृष्ठ संचालन,Back end,server side" [replaceColor.labels] settings = "सेटिंग्स" @@ -3790,7 +3790,7 @@ version = "वर्तमान रिलीज़" title = "API दस्तावेज़ीकरण" header = "API दस्तावेज़ीकरण" desc = "Stirling PDF API एंडपॉइंट्स देखें और परीक्षण करें" -tags = "api,documentation,swagger,endpoints,development" +tags = "api,दस्तावेज़ीकरण,swagger,endpoints,विकास" [cookieBanner.popUp] title = "हम कुकीज़ का उपयोग कैसे करते हैं" @@ -5177,7 +5177,7 @@ upgrade = "अभी अपग्रेड करें →" freeTitle = "सर्वर लाइसेंस" overLimitTitle = "सर्वर लाइसेंस आवश्यक" overLimitBody = "हमारा लाइसेंसिंग प्रति सर्वर अधिकतम {{freeTierLimit}} उपयोगकर्ताओं को मुफ्त अनुमति देता है। आपके पास {{overLimitUserCopy}} Stirling उपयोगकर्ता हैं। बिना बाधा के जारी रखने के लिए, Stirling Server प्लान में अपग्रेड करें - अनलिमिटेड सीट्स, PDF टेक्स्ट एडिटिंग, और पूर्ण एडमिन नियंत्रण $99/server/mo में।" -freeBody = "हमारा Open-Core लाइसेंसिंग प्रति सर्वर अधिकतम {{freeTierLimit}} उपयोगकर्ताओं को मुफ्त अनुमति देता है। बिना बाधा स्केल करने और हमारे नए PDF टेक्स्ट एडिटिंग टूल की प्रारंभिक पहुँच पाने के लिए हम Stirling Server प्लान की सलाह देते हैं - पूर्ण एडिटिंग और अनलिमिटेड सीट्स $99/server/mo में।" +freeBody = "हमारा Open-Core लाइसेंसिंग प्रति सर्वर अधिकतम {{freeTierLimit}} उपयोगकर्ताओं को निःशुल्क अनुमति देता है। बिना रुकावट स्केल करने के लिए, हम Stirling Server प्लान की अनुशंसा करते हैं - असीमित सीटें और SSO समर्थन $99/सर्वर/माह पर।" [onboarding.desktopInstall] title = "डाउनलोड" @@ -6005,7 +6005,7 @@ insufficientPermissions = "आपके पास यह क्रिया क [addText] title = "टेक्स्ट जोड़ें" header = "PDFs में टेक्स्ट जोड़ें" -tags = "text,annotation,label" +tags = "पाठ,टिप्पणी,लेबल" applySignatures = "टेक्स्ट लागू करें" [addText.text] diff --git a/frontend/public/locales/hr-HR/translation.toml b/frontend/public/locales/hr-HR/translation.toml index 3e68d5ce53..4a97a35589 100644 --- a/frontend/public/locales/hr-HR/translation.toml +++ b/frontend/public/locales/hr-HR/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Nadogradi odmah →" freeTitle = "Poslužiteljska licenca" overLimitTitle = "Potrebna poslužiteljska licenca" overLimitBody = "Naše licenciranje dopušta do {{freeTierLimit}} korisnika besplatno po poslužitelju. Imate {{overLimitUserCopy}} Stirling korisnika. Za nesmetan nastavak, nadogradite na Stirling Server plan - neograničena mjesta, uređivanje teksta u PDF-u i puna admin kontrola za $99/server/mo." -freeBody = "Naše Open-Core licenciranje dopušta do {{freeTierLimit}} korisnika besplatno po poslužitelju. Za nesmetano skaliranje i rani pristup našem novom alatu za uređivanje teksta u PDF-u, preporučujemo Stirling Server plan - potpuno uređivanje i neograničena mjesta za $99/server/mo." +freeBody = "Naše licenciranje Open-Core omogućuje do {{freeTierLimit}} korisnika besplatno po poslužitelju. Za neometano skaliranje preporučujemo Stirling Server plan - neograničena mjesta i podrška za SSO za $99/poslužitelj/mj." [onboarding.desktopInstall] title = "Preuzimanje" diff --git a/frontend/public/locales/hu-HU/translation.toml b/frontend/public/locales/hu-HU/translation.toml index 5c70e8a2e3..edbacf219d 100644 --- a/frontend/public/locales/hu-HU/translation.toml +++ b/frontend/public/locales/hu-HU/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Frissítés most →" freeTitle = "Szerverlicenc" overLimitTitle = "Szerverlicenc szükséges" overLimitBody = "Licencelésünk szerverenként legfeljebb {{freeTierLimit}} felhasználót enged ingyen. Önnek {{overLimitUserCopy}} Stirling felhasználója van. A zavartalan használathoz váltson a Stirling Server csomagra – korlátlan hely, PDF szövegszerkesztés és teljes adminisztrátori vezérlés $99/szerver/hó áron." -freeBody = "Az Open-Core licencelésünk szerverenként legfeljebb {{freeTierLimit}} felhasználót enged ingyen. A zavartalan bővüléshez és az új PDF szövegszerkesztő eszköz korai eléréséhez a Stirling Server csomagot ajánljuk – teljes szerkesztés és korlátlan hely $99/szerver/hó áron." +freeBody = "A Open-Core licencünk szerverenként legfeljebb {{freeTierLimit}} felhasználót engedélyez ingyenesen. A zökkenőmentes skálázáshoz a Stirling Server csomagot ajánljuk - korlátlan felhasználó és SSO támogatás $99/szerver/hó." [onboarding.desktopInstall] title = "Letöltés" diff --git a/frontend/public/locales/id-ID/translation.toml b/frontend/public/locales/id-ID/translation.toml index 78fff67354..d260fe5517 100644 --- a/frontend/public/locales/id-ID/translation.toml +++ b/frontend/public/locales/id-ID/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Upgrade sekarang →" freeTitle = "Lisensi Server" overLimitTitle = "Perlu Lisensi Server" overLimitBody = "Lisensi kami mengizinkan hingga {{freeTierLimit}} pengguna gratis per server. Anda memiliki {{overLimitUserCopy}} pengguna Stirling. Untuk terus berjalan tanpa gangguan, upgrade ke paket Stirling Server - kursi tanpa batas, pengeditan teks PDF, dan kontrol admin penuh seharga $99/server/bulan." -freeBody = "Lisensi Open-Core kami mengizinkan hingga {{freeTierLimit}} pengguna gratis per server. Untuk skala tanpa hambatan dan mendapatkan akses awal ke alat pengeditan teks PDF baru kami, kami sarankan paket Stirling Server - pengeditan penuh dan kursi tanpa batas seharga $99/server/bulan." +freeBody = "Lisensi Open-Core kami mengizinkan hingga {{freeTierLimit}} pengguna gratis per server. Untuk meningkatkan skala tanpa gangguan, kami merekomendasikan paket Stirling Server - pengguna tanpa batas dan dukungan SSO seharga $99/server/mo." [onboarding.desktopInstall] title = "Unduh" @@ -5433,7 +5433,7 @@ hideComparison = "Sembunyikan Perbandingan Fitur" featureComparison = "Perbandingan Fitur" from = "Mulai" perMonth = "/bulan" -perSeat = "/seat" +perSeat = "/pengguna" withServer = "+ Paket Server" licensedSeats = "Berlisensi: {{count}} seat" includedInCurrent = "Termasuk dalam Paket Anda" @@ -5594,7 +5594,7 @@ modalTitle = "Mulai - {{planName}}" title = "Pilih Periode Penagihan" savingsNote = "Hemat {{percent}}% dengan penagihan tahunan" basePrice = "Harga Dasar" -seatPrice = "Per Seat" +seatPrice = "Per Pengguna" totalForSeats = "Total ({{count}} seat)" selectMonthly = "Pilih Bulanan" selectYearly = "Pilih Tahunan" diff --git a/frontend/public/locales/it-IT/translation.toml b/frontend/public/locales/it-IT/translation.toml index 806cfa5716..79f6e0e7ad 100644 --- a/frontend/public/locales/it-IT/translation.toml +++ b/frontend/public/locales/it-IT/translation.toml @@ -4176,7 +4176,7 @@ description = "Traccia azioni degli utenti ed eventi di sistema per conformità [admin.settings.security.audit.level] label = "Livello audit" -description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE" +description = "0=SPENTO, 1=BASE, 2=STANDARD, 3=DETTAGLIATO" [admin.settings.security.audit.retentionDays] label = "Conservazione audit (giorni)" @@ -5177,7 +5177,7 @@ upgrade = "Esegui upgrade ora →" freeTitle = "Licenza server" overLimitTitle = "Licenza server necessaria" overLimitBody = "La nostra licenza consente fino a {{freeTierLimit}} utenti gratuiti per server. Hai {{overLimitUserCopy}} utenti Stirling. Per continuare senza interruzioni, esegui l'upgrade al piano Stirling Server - posti illimitati, modifica del testo PDF e pieno controllo admin a $99/server/mese." -freeBody = "La nostra licenza Open-Core consente fino a {{freeTierLimit}} utenti gratuiti per server. Per scalare senza interruzioni e ottenere accesso anticipato al nuovo strumento di modifica testo PDF, consigliamo il piano Stirling Server - modifica completa e posti illimitati a $99/server/mese." +freeBody = "La nostra licenza Open-Core consente fino a {{freeTierLimit}} utenti gratuiti per server. Per scalare senza interruzioni, consigliamo il piano Stirling Server - posti illimitati e supporto SSO a $99/server/mese." [onboarding.desktopInstall] title = "Download" diff --git a/frontend/public/locales/ja-JP/translation.toml b/frontend/public/locales/ja-JP/translation.toml index bceb6975fd..67a4affb39 100644 --- a/frontend/public/locales/ja-JP/translation.toml +++ b/frontend/public/locales/ja-JP/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "今すぐアップグレード →" freeTitle = "サーバーライセンス" overLimitTitle = "サーバーライセンスが必要です" overLimitBody = "当社のライセンスでは、サーバーごとに {{freeTierLimit}} ユーザーまで無料です。現在 {{overLimitUserCopy}} の Stirling ユーザーがいます。中断なく利用を続けるには、Stirling Server プランにアップグレードしてください - 無制限席数、PDF テキスト編集、完全な管理機能が $99/サーバー/月 です。" -freeBody = "当社の オープンコア ライセンスでは、サーバーごとに最大 {{freeTierLimit}} ユーザーまで無料です。中断なく拡張し、新しい PDF テキスト編集ツール に早期アクセスするには、Stirling Server プランをお勧めします。完全編集と 無制限席数 が $99/サーバー/月 です。" +freeBody = "当社のOpen-Coreライセンスでは、サーバーごとに最大{{freeTierLimit}}ユーザーまで無料でご利用いただけます。中断なくスケールするには、Stirling Server プランをおすすめします - 無制限の席数SSO サポートで $99/サーバー/月。" [onboarding.desktopInstall] title = "ダウンロード" diff --git a/frontend/public/locales/ko-KR/translation.toml b/frontend/public/locales/ko-KR/translation.toml index 3dcd0c2377..63f274a25b 100644 --- a/frontend/public/locales/ko-KR/translation.toml +++ b/frontend/public/locales/ko-KR/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "지금 업그레이드 →" freeTitle = "서버 라이선스" overLimitTitle = "서버 라이선스 필요" overLimitBody = "당사의 라이선스는 서버당 무료로 최대 {{freeTierLimit}}명의 사용자를 허용합니다. 현재 {{overLimitUserCopy}}명의 Stirling 사용자가 있습니다. 중단 없이 계속 사용하려면 Stirling Server 플랜으로 업그레이드하세요 - 무제한 좌석, PDF 텍스트 편집, 전체 관리자 제어 제공, $99/서버/월." -freeBody = "당사의 Open-Core 라이선스는 서버당 최대 {{freeTierLimit}}명의 사용자를 무료로 허용합니다. 중단 없이 확장하고 새로운 PDF 텍스트 편집 도구에 조기 액세스하려면 Stirling Server 플랜을 권장합니다 - 전체 편집과 무제한 좌석을 $99/서버/월에 제공합니다." +freeBody = "당사의 Open-Core 라이선스는 서버당 최대 {{freeTierLimit}}명의 사용자를 무료로 허용합니다. 중단 없이 확장하려면 Stirling Server 플랜을 권장합니다 - 무제한 좌석SSO 지원, $99/서버/월." [onboarding.desktopInstall] title = "다운로드" diff --git a/frontend/public/locales/ml-ML/translation.toml b/frontend/public/locales/ml-ML/translation.toml index 92106dd931..4cfe475f0a 100644 --- a/frontend/public/locales/ml-ML/translation.toml +++ b/frontend/public/locales/ml-ML/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "ഇപ്പോൾ അപ്‌ഗ്രേഡ് ചെയ്യു freeTitle = "സെർവർ ലൈസൻസ്" overLimitTitle = "സെർവർ ലൈസൻസ് ആവശ്യമാണ്" overLimitBody = "ഞങ്ങളുടെ ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി {{freeTierLimit}} ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. നിങ്ങള്ക്ക് {{overLimitUserCopy}} Stirling ഉപയോക്താക്കളുണ്ട്. തടസ്സമില്ലാതെ തുടരാൻ, Stirling Server പ്ലാനിലേക്ക് അപ്‌ഗ്രേഡ് ചെയ്യുക - unlimited seats, PDF text editing, പൂർണ്ണ അഡ്മിൻ നിയന്ത്രണം, $99/server/mo." -freeBody = "ഞങ്ങളുടെ Open-Core ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി {{freeTierLimit}} ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. തടസ്സമില്ലാതെ സ്കെയിൽ ചെയ്യാനും പുതിയ PDF text editing tool ന് മുൻകാല ആക്സസ് നേടാനും, Stirling Server പ്ലാൻ ഞങ്ങൾ ശുപാർശ ചെയ്യുന്നു - പൂർണ്ണ എഡിറ്റിംഗും unlimited seats ഉം $99/server/mo." +freeBody = "ഞങ്ങളുടെ Open-Core ലൈസൻസിംഗ് ഓരോ സെർവർക്കും പരമാവധി {{freeTierLimit}} ഉപയോക്താക്കളെ സൗജന്യമായി അനുവദിക്കുന്നു. തടസ്സമില്ലാതെ സ്‌കെയിൽ ചെയ്യാൻ, ഞങ്ങൾ Stirling Server പ്ലാൻ ശുപാർശ ചെയ്യുന്നു - പരിമിതിയില്ലാത്ത സീറ്റുകൾയും SSO പിന്തുണയും for $99/server/mo." [onboarding.desktopInstall] title = "ഡൗൺലോഡ്" diff --git a/frontend/public/locales/nl-NL/translation.toml b/frontend/public/locales/nl-NL/translation.toml index b5c5cf99b4..97532092bf 100644 --- a/frontend/public/locales/nl-NL/translation.toml +++ b/frontend/public/locales/nl-NL/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Nu upgraden →" freeTitle = "Serverlicentie" overLimitTitle = "Serverlicentie vereist" overLimitBody = "Onze licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. Je hebt {{overLimitUserCopy}} Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-plan - onbeperkte plaatsen, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand." -freeBody = "Onze Open-Core-licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. Om ononderbroken te schalen en vroege toegang te krijgen tot onze nieuwe PDF-tekstbewerkingstool, raden we het Stirling Server-plan aan - volledige bewerking en onbeperkte plaatsen voor $99/server/maand." +freeBody = "Onze Open-Core-licentie staat tot {{freeTierLimit}} gebruikers per server gratis toe. Om ononderbroken op te schalen, raden we het Stirling Server-abonnement aan - onbeperkte plaatsen en SSO-ondersteuning voor $99/server/maand." [onboarding.desktopInstall] title = "Downloaden" diff --git a/frontend/public/locales/no-NB/translation.toml b/frontend/public/locales/no-NB/translation.toml index 7dc8b431a9..eaf40fae07 100644 --- a/frontend/public/locales/no-NB/translation.toml +++ b/frontend/public/locales/no-NB/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Oppgrader nå →" freeTitle = "Serverlisens" overLimitTitle = "Serverlisens kreves" overLimitBody = "Lisensieringen vår tillater opptil {{freeTierLimit}} brukere gratis per server. Du har {{overLimitUserCopy}} Stirling-brukere. For å fortsette uten avbrudd, oppgrader til Stirling Server-planen – ubegrensede plasser, PDF-tekstredigering og full admin-kontroll for $99/server/mnd." -freeBody = "Vår Open-Core-lisensiering tillater opptil {{freeTierLimit}} brukere gratis per server. For å skalere uten avbrudd og få tidlig tilgang til vårt nye PDF-tekstredigeringsverktøy, anbefaler vi Stirling Server-planen – full redigering og ubegrensede plasser for $99/server/mnd." +freeBody = "Vår Open-Core-lisensiering tillater opptil {{freeTierLimit}} brukere gratis per server. For å skalere uten avbrudd anbefaler vi Stirling Server-planen - ubegrensede plasser og SSO-støtte for $99/server/mnd." [onboarding.desktopInstall] title = "Last ned" diff --git a/frontend/public/locales/pl-PL/translation.toml b/frontend/public/locales/pl-PL/translation.toml index e652c88d22..ebf36642ae 100644 --- a/frontend/public/locales/pl-PL/translation.toml +++ b/frontend/public/locales/pl-PL/translation.toml @@ -568,7 +568,7 @@ loading = "Ładowanie..." failedToLoad = "Nie udało się załadować danych punktów końcowych. Spróbuj odświeżyć." home = "Strona główna" login = "Logowanie" -top = "Top" +top = "Najlepsze" numberOfVisits = "Liczba wizyt" visitsTooltip = "Wizyty: {0} ({1}% całości)" retry = "Spróbuj ponownie" @@ -1225,7 +1225,7 @@ odtExt = "Tekst OpenDocument (.odt)" pptExt = "PowerPoint (.pptx)" odpExt = "Prezentacja OpenDocument (.odp)" txtExt = "Tekst niesformatowany (.txt)" -rtfExt = "Rich Text Format (.rtf)" +rtfExt = "Format RTF (.rtf)" selectedFiles = "Wybrane pliki" noFileSelected = "Nie wybrano pliku. Użyj panelu plików, aby dodać pliki." convertFiles = "Konwertuj pliki" @@ -5177,7 +5177,7 @@ upgrade = "Ulepsz teraz →" freeTitle = "Licencja serwera" overLimitTitle = "Wymagana licencja serwera" overLimitBody = "Nasza licencja pozwala na maks. {{freeTierLimit}} użytkowników bez opłat na serwer. Masz {{overLimitUserCopy}} użytkowników Stirling. Aby kontynuować bez przerw, przejdź na plan Stirling Server – nielimitowane miejsca, edycja tekstu PDF i pełna kontrola administracyjna za 99 USD/serwer/mies." -freeBody = "Nasza licencja Open-Core pozwala na maks. {{freeTierLimit}} użytkowników bez opłat na serwer. Aby skalować bez przerw i uzyskać wczesny dostęp do nowego narzędzia edycji tekstu PDF, polecamy plan Stirling Server – pełna edycja i nielimitowane miejsca za 99 USD/serwer/mies." +freeBody = "Nasza licencja Open-Core pozwala na maksymalnie {{freeTierLimit}} użytkowników bezpłatnie na serwer. Aby skalować bez zakłóceń, zalecamy plan Stirling Server - nielimitowana liczba miejsc i obsługa SSO za $99/serwer/mies." [onboarding.desktopInstall] title = "Pobierz" @@ -5586,7 +5586,7 @@ emailInvalid = "Wpisz poprawny adres e‑mail" title = "Podaj e‑mail" description = "Użyjemy go do wysłania klucza licencyjnego i rachunków." emailLabel = "Adres e‑mail" -emailPlaceholder = "your@email.com" +emailPlaceholder = "twoj@email.com" continue = "Kontynuuj" modalTitle = "Zaczynamy – {{planName}}" diff --git a/frontend/public/locales/pt-BR/translation.toml b/frontend/public/locales/pt-BR/translation.toml index baaa4215b4..9a6d58d672 100644 --- a/frontend/public/locales/pt-BR/translation.toml +++ b/frontend/public/locales/pt-BR/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Fazer upgrade agora →" freeTitle = "Licença do servidor" overLimitTitle = "Necessária licença do servidor" overLimitBody = "Nossa licença permite até {{freeTierLimit}} usuários grátis por servidor. Você tem {{overLimitUserCopy}} usuários do Stirling. Para continuar sem interrupções, faça upgrade para o plano Stirling Server - assentos ilimitados, edição de texto em PDF e controle total de admin por US$ 99/servidor/mês." -freeBody = "Nossa licença Open-Core permite até {{freeTierLimit}} usuários grátis por servidor. Para escalar sem interrupções e ter acesso antecipado à nova ferramenta de edição de texto em PDF, recomendamos o plano Stirling Server - edição completa e assentos ilimitados por US$ 99/servidor/mês." +freeBody = "Nossa licença Open-Core permite até {{freeTierLimit}} usuários gratuitos por servidor. Para escalar sem interrupções, recomendamos o plano Stirling Server - assentos ilimitados e suporte a SSO por US$ 99/servidor/mês." [onboarding.desktopInstall] title = "Download" @@ -5586,7 +5586,7 @@ emailInvalid = "Digite um endereço de e-mail válido" title = "Informe seu e-mail" description = "Usaremos isso para enviar sua chave de licença e recibos." emailLabel = "Endereço de e-mail" -emailPlaceholder = "your@email.com" +emailPlaceholder = "seu@email.com" continue = "Continuar" modalTitle = "Começar - {{planName}}" diff --git a/frontend/public/locales/pt-PT/translation.toml b/frontend/public/locales/pt-PT/translation.toml index 0691f5f35c..49a02e369e 100644 --- a/frontend/public/locales/pt-PT/translation.toml +++ b/frontend/public/locales/pt-PT/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Atualizar agora →" freeTitle = "Licença do servidor" overLimitTitle = "É necessária licença de servidor" overLimitBody = "A nossa licença permite até {{freeTierLimit}} utilizadores gratuitos por servidor. Tem {{overLimitUserCopy}} utilizadores Stirling. Para continuar sem interrupções, atualize para o plano Stirling Server - lugares ilimitados, edição de texto em PDF e controlo total de administração por $99/servidor/mês." -freeBody = "A nossa licença Open-Core permite até {{freeTierLimit}} utilizadores gratuitos por servidor. Para escalar sem interrupções e obter acesso antecipado à nossa nova ferramenta de edição de texto PDF, recomendamos o plano Stirling Server - edição completa e lugares ilimitados por $99/servidor/mês." +freeBody = "O nosso licenciamento Open-Core permite até {{freeTierLimit}} utilizadores gratuitos por servidor. Para escalar sem interrupções, recomendamos o plano Stirling Server - lugares ilimitados e suporte SSO por $99/servidor/mês." [onboarding.desktopInstall] title = "Transferir" diff --git a/frontend/public/locales/ro-RO/translation.toml b/frontend/public/locales/ro-RO/translation.toml index c323b3354a..005cc5f211 100644 --- a/frontend/public/locales/ro-RO/translation.toml +++ b/frontend/public/locales/ro-RO/translation.toml @@ -3948,7 +3948,7 @@ files = "Fișiere" activity = "Jurnal" help = "Ajutor" account = "Cont" -config = "Config" +config = "Configurare" settings = "Setări" adminSettings = "Setări admin" allTools = "All Tools" @@ -4176,7 +4176,7 @@ description = "Urmărește acțiunile utilizatorilor și evenimentele de sistem [admin.settings.security.audit.level] label = "Nivel audit" -description = "0=OFF, 1=BASIC, 2=STANDARD, 3=VERBOSE" +description = "0=OPRIT, 1=DE BAZĂ, 2=STANDARD, 3=DETALIAT" [admin.settings.security.audit.retentionDays] label = "Păstrare audit (zile)" @@ -5177,7 +5177,7 @@ upgrade = "Fă upgrade acum →" freeTitle = "Licență server" overLimitTitle = "Necesită licență de server" overLimitBody = "Politica noastră de licențiere permite până la {{freeTierLimit}} utilizatori gratuit per server. Ai {{overLimitUserCopy}} utilizatori Stirling. Pentru a continua fără întreruperi, fă upgrade la planul Stirling Server - locuri nelimitate, editare text PDF și control complet de admin pentru $99/server/lună." -freeBody = "Licențierea noastră Open-Core permite până la {{freeTierLimit}} utilizatori gratuit per server. Pentru a scala fără întreruperi și a primi acces timpuriu la noul nostru instrument de editare text PDF, recomandăm planul Stirling Server - editare completă și locuri nelimitate pentru $99/server/lună." +freeBody = "Licențierea noastră Open-Core permite până la {{freeTierLimit}} utilizatori gratuit per server. Pentru scalare fără întreruperi, recomandăm planul Stirling Server - locuri nelimitate și suport SSO pentru $99/server/lună." [onboarding.desktopInstall] title = "Descărcare" @@ -5984,7 +5984,7 @@ warnings = "Avertizări" suggestions = "Note" currentPageFonts = "Fonturi pe această pagină" allFonts = "Toate fonturile" -fallback = "fallback" +fallback = "rezervă" missing = "lipsește" perfectMessage = "Toate fonturile pot fi redate perfect." warningMessage = "Unele fonturi pot să nu fie redate corect." diff --git a/frontend/public/locales/ru-RU/translation.toml b/frontend/public/locales/ru-RU/translation.toml index b72009fa67..69f8f6712c 100644 --- a/frontend/public/locales/ru-RU/translation.toml +++ b/frontend/public/locales/ru-RU/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Обновить сейчас →" freeTitle = "Серверная лицензия" overLimitTitle = "Требуется серверная лицензия" overLimitBody = "Наша лицензия допускает до {{freeTierLimit}} пользователей бесплатно на сервер. У вас {{overLimitUserCopy}} пользователей Stirling. Чтобы продолжить без перебоев, перейдите на тариф Stirling Server — неограниченные места, редактирование текста в PDF и полный админ‑контроль за $99/server/mo." -freeBody = "Наша лицензия Open-Core допускает до {{freeTierLimit}} пользователей бесплатно на сервер. Чтобы масштабироваться без ограничений и раньше получить доступ к новому инструменту редактирования текста в PDF, рекомендуем тариф Stirling Server — полный редактор и неограниченные места за $99/server/mo." +freeBody = "Наша лицензия Open-Core позволяет бесплатно использовать до {{freeTierLimit}} пользователей на сервер. Для бесшовного масштабирования мы рекомендуем план Stirling Server - неограниченное число мест и поддержка SSO за $99/сервер/мес." [onboarding.desktopInstall] title = "Скачать" diff --git a/frontend/public/locales/sk-SK/translation.toml b/frontend/public/locales/sk-SK/translation.toml index 183112a67d..7a196d6998 100644 --- a/frontend/public/locales/sk-SK/translation.toml +++ b/frontend/public/locales/sk-SK/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Upgradovať teraz →" freeTitle = "Serverová licencia" overLimitTitle = "Potrebná serverová licencia" overLimitBody = "Naše licencovanie povoľuje až {{freeTierLimit}} používateľov zdarma na server. Máte {{overLimitUserCopy}} používateľov Stirling. Ak chcete pokračovať bez prerušenia, prejdite na plán Stirling Server - neobmedzené miesta, úpravy textu PDF a plná správa pre $99/server/mo." -freeBody = "Naše licencovanie Open-Core povoľuje až {{freeTierLimit}} používateľov zdarma na server. Ak chcete škálovať bez prerušenia a získať skorý prístup k nášmu novému nástroju na úpravu textu PDF, odporúčame plán Stirling Server - plné úpravy a neobmedzené miesta za $99/server/mo." +freeBody = "Naše licencovanie Open-Core umožňuje až {{freeTierLimit}} používateľov zadarmo na server. Na plynulé škálovanie odporúčame plán Stirling Server - neobmedzený počet používateľov a podporu SSO za $99/server/mes." [onboarding.desktopInstall] title = "Stiahnuť" diff --git a/frontend/public/locales/sl-SI/translation.toml b/frontend/public/locales/sl-SI/translation.toml index f8bc314929..8233f0d422 100644 --- a/frontend/public/locales/sl-SI/translation.toml +++ b/frontend/public/locales/sl-SI/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Nadgradi zdaj →" freeTitle = "Licenca strežnika" overLimitTitle = "Potrebna licenca strežnika" overLimitBody = "Naše licenciranje brezplačno omogoča do {{freeTierLimit}} uporabnikov na strežnik. Imate {{overLimitUserCopy}} uporabnikov Stirling. Za nemoteno uporabo nadgradite na načrt Stirling Server – neomejena mesta, urejanje besedila PDF in popoln skrbniški nadzor za $99/strežnik/mesec." -freeBody = "Naše licenciranje Open-Core brezplačno omogoča do {{freeTierLimit}} uporabnikov na strežnik. Za nemoteno rast in zgodnji dostop do našega novega orodja za urejanje besedila PDF priporočamo načrt Stirling Server – polno urejanje in neomejena mesta za $99/strežnik/mesec." +freeBody = "Naše licenciranje Open-Core omogoča do {{freeTierLimit}} uporabnikov brezplačno na strežnik. Za nemoteno skaliranje priporočamo načrt Stirling Server - neomejena mesta in podpora za SSO za $99/strežnik/mesec." [onboarding.desktopInstall] title = "Prenesi" diff --git a/frontend/public/locales/sr-LATN-RS/translation.toml b/frontend/public/locales/sr-LATN-RS/translation.toml index fd433c25e9..17ce6f3402 100644 --- a/frontend/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/public/locales/sr-LATN-RS/translation.toml @@ -1225,7 +1225,7 @@ odtExt = "OpenDocument tekst (.odt)" pptExt = "PowerPoint (.pptx)" odpExt = "OpenDocument prezentacija (.odp)" txtExt = "Običan tekst (.txt)" -rtfExt = "Rich Text Format (.rtf)" +rtfExt = "Format obogaćenog teksta (.rtf)" selectedFiles = "Izabrane datoteke" noFileSelected = "Nije izabrana nijedna datoteka. Koristite panel datoteka da dodate datoteke." convertFiles = "Konvertuj datoteke" @@ -5177,7 +5177,7 @@ upgrade = "Nadogradite sada →" freeTitle = "Serverska licenca" overLimitTitle = "Potrebna serverska licenca" overLimitBody = "Naše licenciranje dozvoljava do {{freeTierLimit}} korisnika besplatno po serveru. Imate {{overLimitUserCopy}} Stirling korisnika. Da nastavite bez prekida, pređite na Stirling Server plan - neograničena mesta, uređivanje PDF teksta i puna admin kontrola za $99/server/mes." -freeBody = "Naše Open-Core licenciranje dozvoljava do {{freeTierLimit}} korisnika besplatno po serveru. Da se bez prekida skalirate i dobijete rani pristup našem novom alatu za uređivanje PDF teksta, preporučujemo Stirling Server plan - puno uređivanje i neograničena mesta za $99/server/mes." +freeBody = "Naše licenciranje Open-Core dozvoljava do {{freeTierLimit}} korisnika besplatno po serveru. Za neometano skaliranje, preporučujemo plan Stirling Server - neograničena mesta i SSO podrška za $99/server/mo." [onboarding.desktopInstall] title = "Preuzmi" diff --git a/frontend/public/locales/sv-SE/translation.toml b/frontend/public/locales/sv-SE/translation.toml index 5c100fe78e..ccafc28b93 100644 --- a/frontend/public/locales/sv-SE/translation.toml +++ b/frontend/public/locales/sv-SE/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Uppgradera nu →" freeTitle = "Serverlicens" overLimitTitle = "Serverlicens krävs" overLimitBody = "Vår licensiering tillåter upp till {{freeTierLimit}} användare gratis per server. Du har {{overLimitUserCopy}} Stirling-användare. För att fortsätta utan avbrott, uppgradera till Stirling Server-planen - obegränsade platser, PDF-textredigering och full adminkontroll för $99/server/mån." -freeBody = "Vår Open-Core-licens tillåter upp till {{freeTierLimit}} användare gratis per server. För att skala utan avbrott och få tidig åtkomst till vårt nya PDF-textredigeringsverktyg rekommenderar vi Stirling Server-planen - full redigering och obegränsade platser för $99/server/mån." +freeBody = "Vår Open-Core-licens tillåter upp till {{freeTierLimit}} användare gratis per server. För att skala utan avbrott rekommenderar vi Stirling Server plan - obegränsat antal platser och SSO-stöd för $99/server/månad." [onboarding.desktopInstall] title = "Ladda ner" diff --git a/frontend/public/locales/th-TH/translation.toml b/frontend/public/locales/th-TH/translation.toml index 386b699f8a..ce03b4a138 100644 --- a/frontend/public/locales/th-TH/translation.toml +++ b/frontend/public/locales/th-TH/translation.toml @@ -1221,9 +1221,9 @@ pdfaDigitalSignatureWarning = "PDF มีลายเซ็นดิจิทั fileFormat = "รูปแบบไฟล์" wordDoc = "เอกสาร Word" wordDocExt = "เอกสาร Word (.docx)" -odtExt = "OpenDocument Text (.odt)" +odtExt = "ข้อความ OpenDocument (.odt)" pptExt = "PowerPoint (.pptx)" -odpExt = "OpenDocument Presentation (.odp)" +odpExt = "งานนำเสนอ OpenDocument (.odp)" txtExt = "ข้อความล้วน (.txt)" rtfExt = "Rich Text Format (.rtf)" selectedFiles = "ไฟล์ที่เลือก" @@ -5177,7 +5177,7 @@ upgrade = "อัปเกรดเลย →" freeTitle = "ไลเซนส์เซิร์ฟเวอร์" overLimitTitle = "ต้องใช้ไลเซนส์เซิร์ฟเวอร์" overLimitBody = "สิทธิ์การใช้งานของเรารองรับผู้ใช้ได้ฟรีสูงสุด {{freeTierLimit}} คนต่อเซิร์ฟเวอร์ ขณะนี้คุณมีผู้ใช้ Stirling {{overLimitUserCopy}} คน เพื่อใช้งานต่อเนื่อง โปรดอัปเกรดเป็นแพ็กเกจ Stirling Server - ที่นั่งไม่จำกัด แก้ไขข้อความ PDF และควบคุมแอดมินเต็มรูปแบบ ราคา $99/ต่อเซิร์ฟเวอร์/เดือน" -freeBody = "ไลเซนส์แบบ Open-Core ของเรารองรับผู้ใช้ได้ฟรีสูงสุด {{freeTierLimit}} คนต่อเซิร์ฟเวอร์ เพื่อขยายการใช้งานได้ต่อเนื่องและเข้าถึง เครื่องมือแก้ไขข้อความ PDF ล่วงหน้า เราแนะนำแพ็กเกจ Stirling Server - แก้ไขได้เต็มรูปแบบและ ที่นั่งไม่จำกัด ราคา $99/ต่อเซิร์ฟเวอร์/เดือน" +freeBody = "สัญญาอนุญาตแบบ Open-Core ของเราอนุญาตให้ใช้งานฟรีได้สูงสุด {{freeTierLimit}} ผู้ใช้ต่อเซิร์ฟเวอร์หนึ่งเครื่อง เพื่อขยายการใช้งานอย่างต่อเนื่อง เราขอแนะนำแผน Stirling Server - ที่นั่งไม่จำกัด และ รองรับ SSO ในราคา $99/เซิร์ฟเวอร์/เดือน" [onboarding.desktopInstall] title = "ดาวน์โหลด" diff --git a/frontend/public/locales/tr-TR/translation.toml b/frontend/public/locales/tr-TR/translation.toml index 52d46d7fe2..342452574b 100644 --- a/frontend/public/locales/tr-TR/translation.toml +++ b/frontend/public/locales/tr-TR/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "Şimdi yükselt →" freeTitle = "Sunucu Lisansı" overLimitTitle = "Sunucu Lisansı Gerekli" overLimitBody = "Lisansımız, sunucu başına ücretsiz olarak en fazla {{freeTierLimit}} kullanıcıya izin verir. {{overLimitUserCopy}} Stirling kullanıcınız var. Kesintisiz devam etmek için Stirling Server planına yükseltin - sınırsız koltuk, PDF metin düzenleme ve tam yönetici kontrolü $99/server/ay." -freeBody = "Open-Core lisansımız, sunucu başına ücretsiz olarak en fazla {{freeTierLimit}} kullanıcıya izin verir. Kesintisiz ölçeklemek ve yeni PDF metin düzenleme aracımıza erken erişim almak için Stirling Server planını öneririz - tam düzenleme ve sınırsız koltuk $99/server/ay." +freeBody = "Open-Core lisanslamamız, sunucu başına en fazla {{freeTierLimit}} kullanıcıya ücretsiz izin verir. Kesintisiz ölçeklendirme için Stirling Server planını öneririz - sınırsız kullanıcı ve SSO desteği için $99/sunucu/ay." [onboarding.desktopInstall] title = "İndir" diff --git a/frontend/public/locales/uk-UA/translation.toml b/frontend/public/locales/uk-UA/translation.toml index b261dc02a2..a239459907 100644 --- a/frontend/public/locales/uk-UA/translation.toml +++ b/frontend/public/locales/uk-UA/translation.toml @@ -3790,7 +3790,7 @@ version = "Текущий релиз" title = "Документація API" header = "Документація API" desc = "Переглядайте та тестуйте кінцеві точки API Stirling PDF" -tags = "api,documentation,swagger,endpoints,development" +tags = "api,документація,swagger,кінцеві точки,розробка" [cookieBanner.popUp] title = "Як ми використовуємо файли cookie" @@ -5177,7 +5177,7 @@ upgrade = "Оновити зараз →" freeTitle = "Ліцензія сервера" overLimitTitle = "Потрібна ліцензія сервера" overLimitBody = "Наша ліцензія дозволяє до {{freeTierLimit}} користувачів безкоштовно на сервер. У вас {{overLimitUserCopy}} користувачів Stirling. Щоб працювати без перерв, перейдіть на план Stirling Server — необмежена кількість місць, редагування тексту PDF та повний адмін-контроль за $99/server/mo." -freeBody = "Наша Open-Core ліцензія дозволяє до {{freeTierLimit}} користувачів безкоштовно на сервер. Щоб масштабуватися безперервно та отримати ранній доступ до нового інструмента редагування тексту PDF, рекомендуємо план Stirling Server — повне редагування та необмежена кількість місць за $99/server/mo." +freeBody = "Наша ліцензія Open-Core дозволяє до {{freeTierLimit}} користувачів безкоштовно на сервер. Щоб масштабуватися без перерв, рекомендуємо план Stirling Server — необмежена кількість місць і підтримка SSO за $99/сервер/міс." [onboarding.desktopInstall] title = "Завантажити" diff --git a/frontend/public/locales/vi-VN/translation.toml b/frontend/public/locales/vi-VN/translation.toml index 7e9860bb97..b73cc807d6 100644 --- a/frontend/public/locales/vi-VN/translation.toml +++ b/frontend/public/locales/vi-VN/translation.toml @@ -301,7 +301,7 @@ saveSettings = "Lưu cài đặt thao tác" pipelineNamePrompt = "Nhập tên pipeline tại đây" selectOperation = "Chọn thao tác" addOperationButton = "Thêm thao tác" -pipelineHeader = "Pipeline:" +pipelineHeader = "Chuỗi xử lý:" saveButton = "Tải xuống" validateButton = "Xác thực" @@ -5177,7 +5177,7 @@ upgrade = "Nâng cấp ngay →" freeTitle = "Giấy phép Server" overLimitTitle = "Cần giấy phép Server" overLimitBody = "Giấy phép của chúng tôi cho phép tối đa {{freeTierLimit}} người dùng miễn phí mỗi server. Bạn có {{overLimitUserCopy}} người dùng Stirling. Để tiếp tục không gián đoạn, hãy nâng cấp lên gói Stirling Server - số ghế không giới hạn, chỉnh sửa văn bản PDF và toàn quyền quản trị với $99/server/tháng." -freeBody = "Giấy phép Open-Core của chúng tôi cho phép tối đa {{freeTierLimit}} người dùng miễn phí mỗi server. Để mở rộng không gián đoạn và truy cập sớm công cụ chỉnh sửa văn bản PDF mới, chúng tôi khuyến nghị gói Stirling Server - chỉnh sửa đầy đủ và số ghế không giới hạn với $99/server/tháng." +freeBody = "Giấy phép Open-Core của chúng tôi cho phép tối đa {{freeTierLimit}} người dùng miễn phí cho mỗi máy chủ. Để mở rộng quy mô liền mạch, chúng tôi khuyến nghị gói Stirling Server - số lượng người dùng không giới hạnhỗ trợ SSO với giá $99/máy chủ/tháng." [onboarding.desktopInstall] title = "Tải xuống" diff --git a/frontend/public/locales/zh-BO/translation.toml b/frontend/public/locales/zh-BO/translation.toml index 847e423d08..e14cbeb6ba 100644 --- a/frontend/public/locales/zh-BO/translation.toml +++ b/frontend/public/locales/zh-BO/translation.toml @@ -1181,7 +1181,7 @@ selectFilesPlaceholder = "在主视图中选择文件以开始" settings = "设置" conversionCompleted = "转换完成" results = "结果" -defaultFilename = "converted_file" +defaultFilename = "已转换_文件" conversionResults = "转换结果" convertFrom = "从以下格式转换" convertTo = "转换为" @@ -5177,7 +5177,7 @@ upgrade = "立即升级 →" freeTitle = "服务器许可证" overLimitTitle = "需要服务器许可证" overLimitBody = "我们的许可每台服务器最多允许 {{freeTierLimit}} 名用户免费使用。您共有 {{overLimitUserCopy}} 名 Stirling 用户。为避免中断,请升级到 Stirling Server 方案 - 无限席位、PDF 文本编辑,以及每台服务器 $99/月 的完整管理员控制。" -freeBody = "我们的 开源内核(Open-Core) 许可允许每台服务器最多 {{freeTierLimit}} 名用户免费使用。为顺畅扩展并抢先体验全新的 PDF 文本编辑工具,我们推荐 Stirling Server 方案 - 完整编辑与 无限席位,$99/服务器/月。" +freeBody = "我们的Open-Core许可允许每台服务器最多{{freeTierLimit}}名用户免费使用。为实现不中断的扩展,我们推荐 Stirling Server 方案 - 不限席位并提供SSO 支持,$99/服务器/月。" [onboarding.desktopInstall] title = "下载" diff --git a/frontend/public/locales/zh-CN/translation.toml b/frontend/public/locales/zh-CN/translation.toml index fed6c1d605..f79e74cab9 100644 --- a/frontend/public/locales/zh-CN/translation.toml +++ b/frontend/public/locales/zh-CN/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "立即升级 →" freeTitle = "服务器许可证" overLimitTitle = "需要服务器许可证" overLimitBody = "我们的许可允许每台服务器最多免费 {{freeTierLimit}} 名用户。您有 {{overLimitUserCopy}} 名 Stirling 用户。为不间断使用,请升级至 Stirling Server 方案 - 无限席位、PDF 文本编辑,以及 $99/server/mo 的完整管理员控制。" -freeBody = "我们的 Open-Core 许可允许每台服务器最多免费 {{freeTierLimit}} 名用户。为无缝扩展并抢先体验全新的 PDF 文本编辑工具,推荐 Stirling Server 方案 - 完整编辑与 无限席位,$99/server/mo。" +freeBody = "我们的 Open-Core 许可允许每台服务器最多 {{freeTierLimit}} 名用户免费使用。为实现不间断扩展,我们推荐 Stirling Server 方案 - 无限席位SSO 支持,$99/server/mo." [onboarding.desktopInstall] title = "下载" diff --git a/frontend/public/locales/zh-TW/translation.toml b/frontend/public/locales/zh-TW/translation.toml index 8b2f97b5d1..6dff92e6ad 100644 --- a/frontend/public/locales/zh-TW/translation.toml +++ b/frontend/public/locales/zh-TW/translation.toml @@ -5177,7 +5177,7 @@ upgrade = "立即升級 →" freeTitle = "伺服器授權" overLimitTitle = "需要伺服器授權" overLimitBody = "我們的授權允許每台伺服器最多 {{freeTierLimit}} 位使用者免費使用。你有 {{overLimitUserCopy}} 位 Stirling 使用者。若要不中斷使用,請升級至 Stirling Server 方案 - 不限席次、PDF 文字編輯,以及完整管理控制,每台伺服器 $99/月。" -freeBody = "我們的 Open-Core 授權允許每台伺服器最多 {{freeTierLimit}} 位使用者免費使用。若要無縫擴充並搶先體驗全新的 PDF 文字編輯工具,建議升級至 Stirling Server 方案 - 完整編輯與 不限席次,每台伺服器 $99/月。" +freeBody = "我們的 Open-Core 授權允許每台伺服器最多 {{freeTierLimit}} 位使用者免費使用。若要無縫擴充,我們建議選用 Stirling Server 方案 - 不限席次SSO 支援,每伺服器每月 $99。" [onboarding.desktopInstall] title = "下載" diff --git a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx index f118d2ac02..3da69103b5 100644 --- a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx @@ -39,7 +39,7 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide components={{ strong: , }} - defaults="Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted and get early access to our new PDF text editing tool, we recommend the Stirling Server plan - full editing and unlimited seats for $99/server/mo." + defaults="Our Open-Core licensing permits up to {{freeTierLimit}} users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - unlimited seats and SSO support for $99/server/mo." /> ); From fa4d2bc09a2331d6e77297d79187061ef1335e37 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 9 Dec 2025 11:43:18 +0000 Subject: [PATCH 14/15] Fix path to sample file in tour (#5186) # Description of Changes Fix path to sample file in tour --- frontend/src/core/contexts/TourOrchestrationContext.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/contexts/TourOrchestrationContext.tsx b/frontend/src/core/contexts/TourOrchestrationContext.tsx index f8364ba5c3..a594e8322b 100644 --- a/frontend/src/core/contexts/TourOrchestrationContext.tsx +++ b/frontend/src/core/contexts/TourOrchestrationContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, useCallback, useRef } from 'react'; +import { BASE_PATH } from '@app/constants/app'; import { useFileHandler } from '@app/hooks/useFileHandler'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useNavigationActions } from '@app/contexts/NavigationContext'; @@ -110,7 +111,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }> const loadSampleFile = useCallback(async () => { try { - const response = await fetch('samples/Sample.pdf'); + const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`); const blob = await response.blob(); const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); From c980ee10c0c8bebc77d6dc4a1432ffde09bfe488 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 9 Dec 2025 11:45:01 +0000 Subject: [PATCH 15/15] Backport fixes from SaaS (#5187) # Description of Changes - Add new skeleton loader style type (block - nothing's currently using it but it might as well be available) - Make Dev API overridable (and set to the new docs that actually work while Swagger docs don't work properly) --- .../core/components/shared/SkeletonLoader.tsx | 67 +++++++++++++------ frontend/src/core/constants/links.ts | 1 + .../core/data/useTranslatedToolRegistry.tsx | 3 +- 3 files changed, 48 insertions(+), 23 deletions(-) create mode 100644 frontend/src/core/constants/links.ts diff --git a/frontend/src/core/components/shared/SkeletonLoader.tsx b/frontend/src/core/components/shared/SkeletonLoader.tsx index 63c4bd22a3..a95949e8f2 100644 --- a/frontend/src/core/components/shared/SkeletonLoader.tsx +++ b/frontend/src/core/components/shared/SkeletonLoader.tsx @@ -2,23 +2,44 @@ import React from 'react'; import { Box, Group, Stack } from '@mantine/core'; interface SkeletonLoaderProps { - type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer'; + type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer' | 'block'; count?: number; animated?: boolean; + width?: number | string; + height?: number | string; + radius?: number | string; } -const SkeletonLoader: React.FC = ({ - type, - count = 8, - animated = true +const SkeletonLoader: React.FC = ({ + type, + count = 8, + animated = true, + width, + height, + radius = 8, }) => { const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {}; + // Generic block skeleton for inline text/inputs/etc. + const renderBlock = () => ( + + ); + const renderPageGridSkeleton = () => ( -

{Array.from({ length: count }).map((_, i) => ( = ({ w="100%" h={240} bg="gray.1" - style={{ + style={{ borderRadius: '8px', ...animationStyle, animationDelay: animated ? `${i * 0.1}s` : undefined @@ -37,10 +58,10 @@ const SkeletonLoader: React.FC = ({ ); const renderFileGridSkeleton = () => ( -
{Array.from({ length: count }).map((_, i) => ( = ({ w="100%" h={280} bg="gray.1" - style={{ + style={{ borderRadius: '8px', ...animationStyle, animationDelay: animated ? `${i * 0.1}s` : undefined @@ -76,18 +97,20 @@ const SkeletonLoader: React.FC = ({ {/* Main content skeleton */} - ); switch (type) { + case 'block': + return renderBlock(); case 'pageGrid': return renderPageGridSkeleton(); case 'fileGrid': @@ -101,4 +124,4 @@ const SkeletonLoader: React.FC = ({ } }; -export default SkeletonLoader; \ No newline at end of file +export default SkeletonLoader; diff --git a/frontend/src/core/constants/links.ts b/frontend/src/core/constants/links.ts new file mode 100644 index 0000000000..c48ea04ab3 --- /dev/null +++ b/frontend/src/core/constants/links.ts @@ -0,0 +1 @@ +export const devApiLink = "https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/"; diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 0844b94d1a..3208e4ef78 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useTranslation } from "react-i18next"; +import { devApiLink } from "@app/constants/links"; import SplitPdfPanel from "@app/tools/Split"; import CompressPdfPanel from "@app/tools/Compress"; import OCRPanel from "@app/tools/OCR"; @@ -784,7 +785,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { description: t("home.devApi.desc", "Link to API documentation"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, - link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html", + link: devApiLink, synonyms: getSynonyms(t, "devApi"), supportsAutomate: false, automationSettings: null