From 8a0b12b5abbe366118af5cdc9e030f81bbeeb643 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:21:28 +0100 Subject: [PATCH 1/9] Remove ffmpeg from published Docker images (#6791) ## Summary Published Docker images (`stirling-pdf:latest`, `:2.13.1`) still shipped the full `ffmpeg` package even though it was disabled in source back in #6053. **Root cause:** `push-docker.yml` passed a hardcoded `BASE_VERSION=1.0.0` build-arg for the regular image, overriding the Dockerfile's `ARG BASE_VERSION=1.0.2` default. Base `1.0.0` is the original base that still does the explicit `ffmpeg` apt install, so the published image never picked up the removal. --- .github/aur/stirling-pdf-desktop/PKGBUILD | 2 +- .github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +- .github/workflows/push-docker.yml | 2 +- build.gradle | 2 +- frontend/editor/src-tauri/tauri.conf.json | 2 +- frontend/editor/src/core/testing/serverExperienceSimulations.ts | 2 +- .../src/proprietary/testing/serverExperienceSimulations.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index d35c874238..5a8563e942 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.13.1 +pkgver=2.13.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 2e71087c7c..6ed6757b92 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.13.1 +pkgver=2.13.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ad7653eab3..1f38f0ef57 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -155,9 +155,9 @@ jobs: cache-to: type=gha,mode=max,scope=stirling-pdf-latest tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth). build-args: | VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }} - BASE_VERSION=1.0.0 platforms: linux/amd64,linux/arm64/v8 provenance: true sbom: true diff --git a/build.gradle b/build.gradle index b8423fc920..6b324598f3 100644 --- a/build.gradle +++ b/build.gradle @@ -78,7 +78,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.13.1' + version = '2.13.2' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 55dba6de7d..3eb545c231 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling-PDF", - "version": "2.13.1", + "version": "2.13.2", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index 8338efb9e9..2e9db84852 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.13.1", + appVersion: "2.13.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index 1e19f4be0c..65d611fc09 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.13.1", + appVersion: "2.13.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, From e35594f946835dcb7ac5dd32693cda18cb2244f8 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 24 Jun 2026 10:34:59 +0200 Subject: [PATCH 2/9] chore(build): centralize Gradle dependency version management (#6499) # Description of Changes This change centralizes several dependency version declarations into shared Gradle version properties and updates module build files to reference those properties instead of hardcoded version strings. ### What was changed - Added centralized version properties in the root `build.gradle` for: - commons-io - commons-lang3 - rhino - okhttp BOM - gson - guava - bucket4j - archunit - batik - jpdfium - JWT - AWS SDK - Testcontainers - Replaced hardcoded dependency versions across multiple modules with shared version variables. - Updated `resolutionStrategy.force` declarations to use centralized version properties. - Updated dependency constraints and BOM references to use shared version variables. - Removed module-specific duplicate version declarations from `app/proprietary/build.gradle`. - Standardized dependency declarations across `common`, `core`, `proprietary`, and `saas` modules. ## Why the change was made - Reduce duplication of dependency version definitions. - Simplify future dependency upgrades and maintenance. - Ensure consistent dependency versions across all modules. - Improve readability and reduce the risk of version drift between subprojects. - Make security-related dependency overrides easier to maintain from a single location. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- app/common/build.gradle | 12 ++++++------ app/core/build.gradle | 13 ++++++------- app/proprietary/build.gradle | 32 +++++++++++++------------------- app/saas/build.gradle | 2 +- build.gradle | 30 +++++++++++++++++++++--------- 5 files changed, 47 insertions(+), 42 deletions(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 170f964af4..516edd4897 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -29,13 +29,13 @@ spotless { } } dependencies { - api 'com.google.guava:guava:33.6.0-jre' + api "com.google.guava:guava:${guavaVersion}" api 'org.springframework.boot:spring-boot-starter-webmvc' api 'org.springframework.boot:spring-boot-starter-aspectj' api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1' api 'com.fathzer:javaluator:3.0.6' api 'com.posthog.java:posthog:1.2.0' - api 'org.apache.commons:commons-lang3:3.20.0' + api "org.apache.commons:commons-lang3:${commonsLang3}" api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8' api "org.apache.pdfbox:pdfbox:$pdfboxVersion" @@ -60,7 +60,7 @@ dependencies { exclude group: 'com.google.code.gson', module: 'gson' } - api 'com.stirling:jpdfium:1.0.2' + api "com.stirling:jpdfium:${jpdfiumVersion}" // -PjpdfiumPlatforms=all| def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() @@ -75,12 +75,12 @@ dependencies { } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}") jpdfiumPlatforms.each { platform -> - runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2" + runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}" } // Bucket4j (local in-process token bucket for RateLimitStore default impl) - implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0' + implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}" // ArchUnit: enforces module dependency direction (see ArchitectureTest) - testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' + testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}" } diff --git a/app/core/build.gradle b/app/core/build.gradle index 21acdb36e0..d77fbdd438 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -67,7 +67,7 @@ dependencies { exclude group: 'com.fasterxml.jackson.jaxrs' exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations' } - implementation 'commons-io:commons-io:2.22.0' + implementation "commons-io:commons-io:$commonsIoVersion" implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion" implementation 'io.micrometer:micrometer-core' @@ -81,25 +81,24 @@ dependencies { implementation 'org.verapdf:validation-model:1.28.2' // CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13 - implementation 'org.mozilla:rhino:1.9.1' + implementation "org.mozilla:rhino:${rhinoVersion}" // veraPDF still uses javax.xml.bind, not the new jakarta namespace implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.9' implementation 'com.sun.xml.bind:jaxb-core:4.0.7' - implementation 'org.apache.poi:poi-ooxml:5.5.1' - // CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7) - implementation 'com.google.code.gson:gson:2.13.2' + // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) + implementation "com.google.code.gson:gson:${gsonVersion}" implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4' implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv implementation 'org.apache.poi:poi-ooxml:5.5.1' // Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom) // Replaces batik-all which included unused codec, svggen, transcoder, script modules - implementation 'org.apache.xmlgraphics:batik-bridge:1.19' + implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}" // Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init - runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19' + runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}" // PDFBox Graphics2D bridge for Batik SVG to PDF conversion implementation 'de.rototor.pdfbox:graphics2d:3.0.5' diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 821def1fec..7fb12de5ce 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -3,12 +3,6 @@ repositories { maven { url = "https://build.shibboleth.net/maven/releases" } } -ext { - jwtVersion = '0.13.0' - awsSdkVersion = '2.44.12' - testcontainersMinioVersion = '1.21.4' -} - bootRun { enabled = false } @@ -41,7 +35,7 @@ spotless { } dependencies { implementation project(':common') - api 'com.google.guava:guava:33.6.0-jre' + api "com.google.guava:guava:${guavaVersion}" api 'org.springframework:spring-jdbc' api 'org.springframework:spring-webmvc' @@ -61,37 +55,37 @@ dependencies { api 'com.github.ben-manes.caffeine:caffeine' implementation 'org.springframework.boot:spring-boot-starter-data-redis' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46' - implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0' + implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}" // Lettuce-backed Bucket4j ProxyManager used by ValkeyRateLimitStore for cluster-wide // token-bucket rate limiting (parity with in-process Bucket4j semantics; no fixed-window // boundary doubling). - implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.19.0' + implementation "com.bucket4j:bucket4j_jdk17-lettuce:${bucket4jVersion}" // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion" - implementation 'com.google.code.gson:gson:2.13.2' + implementation "com.google.code.gson:gson:${gsonVersion}" api 'io.micrometer:micrometer-registry-prometheus' - api "io.jsonwebtoken:jjwt-api:$jwtVersion" - runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion" - runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion" + api "io.jsonwebtoken:jjwt-api:${jwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-impl:${jwtVersion}" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jwtVersion}" runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases runtimeOnly 'org.postgresql:postgresql:42.7.11' implementation('com.coveo:saml-client:5.0.0') { exclude group: 'org.opensaml', module: 'opensaml-core' } - implementation "software.amazon.awssdk:s3:$awsSdkVersion" - implementation "software.amazon.awssdk:url-connection-client:$awsSdkVersion" + implementation "software.amazon.awssdk:s3:${awsSdkVersion}" + implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}" // Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without // manually-started instances. Tests skip cleanly when Docker is unavailable. - testImplementation "org.testcontainers:testcontainers:$testcontainersMinioVersion" - testImplementation "org.testcontainers:minio:$testcontainersMinioVersion" - testImplementation "org.testcontainers:localstack:$testcontainersMinioVersion" - testImplementation "org.testcontainers:junit-jupiter:$testcontainersMinioVersion" + testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}" + testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}" + testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}" + testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}" } tasks.register('prepareKotlinBuildScriptModel') {} diff --git a/app/saas/build.gradle b/app/saas/build.gradle index 560ad58f5f..d6ae305def 100644 --- a/app/saas/build.gradle +++ b/app/saas/build.gradle @@ -41,5 +41,5 @@ dependencies { api 'org.flywaydb:flyway-core' runtimeOnly 'org.flywaydb:flyway-database-postgresql' - testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' + testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}" } diff --git a/build.gradle b/build.gradle index 6b324598f3..3b91ca502c 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,19 @@ ext { commonmarkVersion = "0.28.0" googleJavaFormatVersion = "1.28.0" logback = "1.5.32" + commonsIoVersion = "2.22.0" + commonsLang3 = "3.20.0" + rhinoVersion = "1.9.1" + okhttpBomVersion = "5.3.2" + gsonVersion = "2.14.0" + guavaVersion = "33.6.0-jre" + bucket4jVersion = "8.19.0" + archunitVersion = "1.4.2" + batikVersion = "1.19" + jpdfiumVersion = "1.0.2" + jwtVersion = "0.13.0" + awsSdkVersion = "2.44.12" + testcontainersMinioVersion = "1.21.4" // junit-platform-launcher version managed by Spring Boot BOM modernJavaVersion = 25 } @@ -203,12 +216,12 @@ subprojects { // - CVE-2022-25647: gson 2.8.9+ (explicit dependency overrides tabula 2.8.7) // - CVE-2025-66453: rhino 1.7.15 (explicit dependency overrides verapdf 1.7.13) // Fallback strategy force declarations for additional safety: - resolutionStrategy.force 'com.google.code.gson:gson:2.13.2' - resolutionStrategy.force 'org.mozilla:rhino:1.9.1' + resolutionStrategy.force "com.google.code.gson:gson:${gsonVersion}" + resolutionStrategy.force "org.mozilla:rhino:${rhinoVersion}" // CVE-2025-48924: commons-lang3 3.20.0 DoS prevention - resolutionStrategy.force 'org.apache.commons:commons-lang3:3.20.0' - // CVE-2024-47554: commons-io 2.21.0 DoS prevention - resolutionStrategy.force 'commons-io:commons-io:2.21.0' + resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}" + // CVE-2024-47554: commons-io DoS prevention + resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}" // Keep BouncyCastle modules aligned to avoid runtime linkage errors resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}" resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}" @@ -221,7 +234,7 @@ subprojects { } dependencies { // Override BOM-managed commons-lang3 for CVE-2025-48924 fix - dependency 'org.apache.commons:commons-lang3:3.20.0' + dependency "org.apache.commons:commons-lang3:$commonsLang3" } } @@ -243,7 +256,7 @@ subprojects { testRuntimeOnly 'org.mockito:mockito-inline:5.2.0' testRuntimeOnly "org.junit.platform:junit-platform-launcher" - testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2") + testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}") testImplementation "com.squareup.okhttp3:mockwebserver" } @@ -599,7 +612,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly "org.junit.platform:junit-platform-launcher" - testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2") + testImplementation platform("com.squareup.okhttp3:okhttp-bom:${okhttpBomVersion}") testImplementation "com.squareup.okhttp3:mockwebserver" } @@ -670,7 +683,6 @@ tasks.register('buildRestartHelper', Jar) { } } - tasks.withType(Test).configureEach { // maxParallelForks: parallelise JUnit across cores // Half of available CPUs is a safe default; bump if your tests are I/O-bound. From 26021425e3c429ef4eaa942bf165b71190c5eccb Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 24 Jun 2026 10:36:05 +0200 Subject: [PATCH 3/9] chore(ci): upgrade Gradle to 9.6.0 across workflows, Docker builds, and wrapper (#6790) # Description of Changes ## What was changed - Updated all GitHub Actions workflows using Gradle from older versions (9.3.1 and 9.5.1) to Gradle 9.6.0. - Updated the Gradle Wrapper distribution URL to use Gradle 9.6.0. - Updated all Gradle-based Docker build stages to use the `gradle:9.6.0-jdk25` image and corresponding image digest. - Aligned CI, Docker, and local development environments on the same Gradle version. - Included the regenerated `gradlew` script changes produced by the Gradle wrapper update process. ## Why the change was made - Ensures consistent Gradle versions across local development, CI workflows, and Docker builds. - Takes advantage of the latest Gradle 9.6.0 improvements, fixes, and compatibility updates. - Reduces the risk of version mismatches causing build or deployment inconsistencies. - Simplifies maintenance by standardizing the build toolchain throughout the repository. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/coverage-aggregate.yml | 2 +- .github/workflows/db-migration-test.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/push-docker.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/tauri-build.yml | 4 ++-- .github/workflows/test-build-docker.yml | 2 +- .github/workflows/testdriver.yml | 2 +- docker/backend/Dockerfile | 2 +- docker/embedded/Dockerfile | 2 +- docker/embedded/Dockerfile.fat | 2 +- docker/embedded/Dockerfile.ultra-lite | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 ++-- 21 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 95afebfe4c..e478f95c89 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -222,7 +222,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 33212b2330..03b172c4c3 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -43,7 +43,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 0a252100a0..37a42c419f 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -58,7 +58,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true - name: Install Task diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 1159c75dc6..b984f7d47d 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true - name: Install Task diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 49c19433c3..46ce829a87 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true - name: Install Task diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 35c252c4bd..bbfaf09363 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -51,7 +51,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.3.1 + gradle-version: 9.6.0 cache-disabled: true - name: Set up Python diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 114855c4bc..4b5ca8b674 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true # No `-PnoSpotless` here yet because the upstream cache layer matches the diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index add65d6a8e..faa3c2a280 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -61,7 +61,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true - name: Set up Docker Buildx diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 8bf89bf210..41d3f35c6e 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -349,7 +349,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f29714d94e..f5fb743a3f 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -73,7 +73,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -148,7 +148,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Setup Node.js if: matrix.variant.build_frontend == true @@ -252,7 +252,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 1f38f0ef57..75b1aa60c8 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -78,7 +78,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 00c368c75f..9696aa419c 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -48,7 +48,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 65b1e40f36..944940e973 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -122,7 +122,7 @@ jobs: uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: frontend/editor/src-tauri - # Stable key shared across workflows so the nightly warmer. + # Stable key shared across workflows so the nightly warmer. # rust-cache still appends OS + rustc + Cargo.lock. shared-key: tauri-${{ matrix.name }} save-if: ${{ github.ref == 'refs/heads/main' }} @@ -150,7 +150,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 0b692a008a..4fd00a494c 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -106,7 +106,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 cache-disabled: true - name: Install Task diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 05fce0199c..e9774694e7 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -51,7 +51,7 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 with: - gradle-version: 9.5.1 + gradle-version: 9.6.0 - name: Build with Gradle run: ./gradlew build diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 40abd00933..b2b7aa644b 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application (backend only, no frontend) -FROM gradle:9.3.1-jdk25@sha256:85aec999629f4774a383cb792da4b598bdf5a7e69c4b9570bb70c0f919179183 AS app-build +FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build # JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 89ed06397a..7ea8201f59 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build +FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 0beb8406ec..71f2a12414 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -6,7 +6,7 @@ ARG BASE_VERSION=1.0.2 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS app-build +FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS app-build ARG TASK_VERSION=3.49.1 RUN apt-get update \ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 9f77716646..01048e864d 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -2,7 +2,7 @@ # Single JAR contains both frontend and backend with minimal dependencies # Stage 1: Build application with embedded frontend -FROM gradle:9.5.1-jdk25@sha256:8de3543f1772bb66be3b275893e5977b6d8bd2b0d25551faa5846a821d1f0600 AS build +FROM gradle:9.6.0-jdk25@sha256:e3905233ae349e72016daf8a0e19f085a1dd89ded8ec88b3d8335d3fd0b350f4 AS build # Install Node.js and npm for frontend build ARG TASK_VERSION=3.49.1 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index df6a6ad763..eb84db68da 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f79..249efbb032 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: From f7f7b8790e0f43935e315f4be2b88e251ffe225c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:00:43 +0100 Subject: [PATCH 4/9] fix update notification visibility and install flicker (#6776) # Description of Changes - Closes #6754 - Update popup now hidden on mobile, for non-admins, and never on SaaS - Respects admin "Show Update Notifications" setting (`showUpdate` / `showUpdateOnlyAdmin`, now default on) - Fixes update modal flickering during desktop install --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/model/ApplicationProperties.java | 4 +- .../controller/api/misc/ConfigController.java | 9 ++ .../src/main/resources/settings.yml.template | 4 +- .../api/misc/ConfigControllerMoreTest.java | 3 + .../api/misc/ConfigControllerTest.java | 3 + .../service/AppUpdateAuthServiceTest.java | 93 +++++++++++++++++++ .../components/shared/UpdateModal.test.tsx | 66 +++++++++++++ .../core/components/shared/UpdateModal.tsx | 31 ++++--- .../components/shared/UpdateStartupPopup.tsx | 9 +- .../components/shared/updatePopupGate.test.ts | 33 +++++++ .../core/components/shared/updatePopupGate.ts | 10 ++ frontend/editor/src/core/types/appConfig.ts | 1 + .../components/shared/UpdateStartupPopup.tsx | 5 + 13 files changed, 255 insertions(+), 16 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/AppUpdateAuthServiceTest.java create mode 100644 frontend/editor/src/core/components/shared/UpdateModal.test.tsx create mode 100644 frontend/editor/src/core/components/shared/updatePopupGate.test.ts create mode 100644 frontend/editor/src/core/components/shared/updatePopupGate.ts create mode 100644 frontend/editor/src/saas/components/shared/UpdateStartupPopup.tsx 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 b955a2fe32..e80d6b2360 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 @@ -869,8 +869,8 @@ public class ApplicationProperties { public static class System { private String defaultLocale; private boolean googlevisibility; - private boolean showUpdate; - private boolean showUpdateOnlyAdmin; + private boolean showUpdate = true; + private boolean showUpdateOnlyAdmin = true; private boolean showSettingsWhenNoLogin = true; private boolean customHTMLFiles; private String tessdataDir; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 1b2fcaddfa..3ea525a64e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup; import stirling.software.SPDF.controller.api.security.TimestampController; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; +import stirling.software.common.configuration.interfaces.ShowAdminInterface; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.ServerCertificateServiceInterface; import stirling.software.common.service.UserServiceInterface; @@ -37,6 +38,7 @@ public class ConfigController { private final EndpointConfiguration endpointConfiguration; private final ServerCertificateServiceInterface serverCertificateService; private final UserServiceInterface userService; + private final ShowAdminInterface showAdmin; private final stirling.software.common.service.LicenseServiceInterface licenseService; private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig; @@ -48,6 +50,8 @@ public class ConfigController { ServerCertificateServiceInterface serverCertificateService, @org.springframework.beans.factory.annotation.Autowired(required = false) UserServiceInterface userService, + @org.springframework.beans.factory.annotation.Autowired(required = false) + ShowAdminInterface showAdmin, @org.springframework.beans.factory.annotation.Autowired(required = false) stirling.software.common.service.LicenseServiceInterface licenseService, stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) { @@ -56,6 +60,7 @@ public class ConfigController { this.endpointConfiguration = endpointConfiguration; this.serverCertificateService = serverCertificateService; this.userService = userService; + this.showAdmin = showAdmin; this.licenseService = licenseService; this.externalAppDepConfig = externalAppDepConfig; } @@ -315,6 +320,10 @@ public class ConfigController { configData.put( "enableAlphaFunctionality", applicationProperties.getSystem().isEnableAlphaFunctionality()); + boolean shouldShowUpdate = + applicationProperties.getSystem().isShowUpdate() + && (showAdmin == null || showAdmin.getShowUpdateOnlyAdmins()); + configData.put("shouldShowUpdate", shouldShowUpdate); configData.put( "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 9bce7d1c55..1e9e103742 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -170,8 +170,8 @@ system: defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) - showUpdate: false # see when a new update is available - showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' + showUpdate: true # see when a new update is available + showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled. customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java index e62bf4fc85..d0974f07c3 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java @@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletRequest; import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.ExternalAppDepConfig; import stirling.software.common.configuration.AppConfig; +import stirling.software.common.configuration.interfaces.ShowAdminInterface; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.LicenseServiceInterface; import stirling.software.common.service.ServerCertificateServiceInterface; @@ -45,6 +46,7 @@ class ConfigControllerMoreTest { @Mock private EndpointConfiguration endpointConfiguration; @Mock private ServerCertificateServiceInterface serverCertificateService; @Mock private UserServiceInterface userService; + @Mock private ShowAdminInterface showAdmin; @Mock private LicenseServiceInterface licenseService; @Mock private ExternalAppDepConfig externalAppDepConfig; @Mock private AppConfig appConfig; @@ -70,6 +72,7 @@ class ConfigControllerMoreTest { endpointConfiguration, serverCertificateService, userService, + showAdmin, licenseService, externalAppDepConfig); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java index cab1622c69..faf5c70985 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerTest.java @@ -21,6 +21,7 @@ import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration.DisableReason; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.common.configuration.AppConfig; +import stirling.software.common.configuration.interfaces.ShowAdminInterface; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.System; import stirling.software.common.service.LicenseServiceInterface; @@ -35,6 +36,7 @@ class ConfigControllerTest { @Mock private EndpointConfiguration endpointConfiguration; @Mock private ServerCertificateServiceInterface serverCertificateService; @Mock private UserServiceInterface userService; + @Mock private ShowAdminInterface showAdmin; @Mock private LicenseServiceInterface licenseService; private ConfigController configController; @@ -48,6 +50,7 @@ class ConfigControllerTest { endpointConfiguration, serverCertificateService, userService, + showAdmin, licenseService, mock(stirling.software.SPDF.config.ExternalAppDepConfig.class)); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/AppUpdateAuthServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/AppUpdateAuthServiceTest.java new file mode 100644 index 0000000000..ccd6582409 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/AppUpdateAuthServiceTest.java @@ -0,0 +1,93 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; + +class AppUpdateAuthServiceTest { + + private final UserRepository userRepository = mock(UserRepository.class); + + private AppUpdateAuthService service(boolean showUpdate, boolean onlyAdmin) { + ApplicationProperties props = new ApplicationProperties(); + ApplicationProperties.System system = new ApplicationProperties.System(); + system.setShowUpdate(showUpdate); + system.setShowUpdateOnlyAdmin(onlyAdmin); + props.setSystem(system); + return new AppUpdateAuthService(userRepository, props); + } + + private void withAuthentication(Authentication auth, Runnable assertions) { + try (MockedStatic holder = mockStatic(SecurityContextHolder.class)) { + SecurityContext context = mock(SecurityContext.class); + when(context.getAuthentication()).thenReturn(auth); + holder.when(SecurityContextHolder::getContext).thenReturn(context); + assertions.run(); + } + } + + private Authentication authenticatedAs(String username, String roles) { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getName()).thenReturn(username); + User user = mock(User.class); + when(user.getRolesAsString()).thenReturn(roles); + when(userRepository.findByUsername(username)).thenReturn(Optional.of(user)); + return auth; + } + + @Test + void hidesForEveryoneWhenShowUpdateOff() { + withAuthentication( + authenticatedAs("admin", "ROLE_ADMIN"), + () -> assertFalse(service(false, true).getShowUpdateOnlyAdmins())); + } + + @Test + void showsToAllAuthenticatedWhenNotAdminOnly() { + withAuthentication( + authenticatedAs("bob", "ROLE_USER"), + () -> assertTrue(service(true, false).getShowUpdateOnlyAdmins())); + } + + @Test + void hidesFromAnonymousWhenAdminOnly() { + withAuthentication(null, () -> assertFalse(service(true, true).getShowUpdateOnlyAdmins())); + } + + @Test + void hidesFromAnonymousUserPrincipalWhenAdminOnly() { + Authentication auth = mock(Authentication.class); + when(auth.isAuthenticated()).thenReturn(true); + when(auth.getName()).thenReturn("anonymousUser"); + withAuthentication(auth, () -> assertFalse(service(true, true).getShowUpdateOnlyAdmins())); + } + + @Test + void showsToAdminWhenAdminOnly() { + withAuthentication( + authenticatedAs("admin", "ROLE_ADMIN"), + () -> assertTrue(service(true, true).getShowUpdateOnlyAdmins())); + } + + @Test + void hidesFromNonAdminWhenAdminOnly() { + withAuthentication( + authenticatedAs("bob", "ROLE_USER"), + () -> assertFalse(service(true, true).getShowUpdateOnlyAdmins())); + } +} diff --git a/frontend/editor/src/core/components/shared/UpdateModal.test.tsx b/frontend/editor/src/core/components/shared/UpdateModal.test.tsx new file mode 100644 index 0000000000..e2026b33bb --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateModal.test.tsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { MachineInfo, UpdateSummary } from "@app/services/updateService"; + +const getFullUpdateInfo = vi.fn(); + +vi.mock("@app/services/updateService", () => ({ + updateService: { + getFullUpdateInfo: (...args: unknown[]) => getFullUpdateInfo(...args), + getDownloadUrl: () => null, + compareVersions: (a: string, b: string) => (a === b ? 0 : a > b ? 1 : -1), + }, +})); + +import UpdateModal from "@app/components/shared/UpdateModal"; + +const summary: UpdateSummary = { + latest_version: "2.0.0", + max_priority: "normal", + any_breaking: false, +}; + +const machine = (): MachineInfo => ({ + machineType: "Client-win", + activeSecurity: false, + licenseType: "NORMAL", +}); + +function wrap(machineInfo: MachineInfo) { + return ( + + {}} + currentVersion="1.0.0" + updateSummary={summary} + machineInfo={machineInfo} + /> + + ); +} + +describe("UpdateModal", () => { + afterEach(() => getFullUpdateInfo.mockReset()); + + it("does not refetch version info when machineInfo identity changes but values are equal", async () => { + getFullUpdateInfo.mockResolvedValue({ + latest_version: "2.0.0", + new_versions: [], + }); + + const { rerender } = render(wrap(machine())); + + await waitFor(() => + expect( + screen.queryByText("update.loadingDetailedInfo"), + ).not.toBeInTheDocument(), + ); + expect(getFullUpdateInfo).toHaveBeenCalledTimes(1); + + rerender(wrap(machine())); + + await waitFor(() => expect(getFullUpdateInfo).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx index 1399325b03..c18549360c 100644 --- a/frontend/editor/src/core/components/shared/UpdateModal.tsx +++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx @@ -130,18 +130,27 @@ const UpdateModal: React.FC = ({ new Set([0]), ); + const { machineType, activeSecurity, licenseType } = machineInfo; useEffect(() => { - if (opened) { - setLoading(true); - setExpandedVersions(new Set([0])); - updateService - .getFullUpdateInfo(currentVersion, machineInfo) - .then((info) => { - setFullUpdateInfo(info); - setLoading(false); - }); - } - }, [opened, currentVersion, machineInfo]); + if (!opened) return; + let cancelled = false; + setLoading(true); + setExpandedVersions(new Set([0])); + updateService + .getFullUpdateInfo(currentVersion, { + machineType, + activeSecurity, + licenseType, + }) + .then((info) => { + if (cancelled) return; + setFullUpdateInfo(info); + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [opened, currentVersion, machineType, activeSecurity, licenseType]); const toggleVersion = (index: number) => { setExpandedVersions((prev) => { diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx index 62f6dbb999..fd3de26bdc 100644 --- a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx @@ -1,7 +1,9 @@ import { useEffect, useRef, useState } from "react"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useFrontendVersionInfo } from "@app/hooks/useFrontendVersionInfo"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { updateService, type UpdateSummary } from "@app/services/updateService"; +import { isUpdatePopupAllowed } from "@app/components/shared/updatePopupGate"; import UpdateModal from "@app/components/shared/UpdateModal"; /** @@ -26,6 +28,7 @@ const SNOOZE_DURATION_MS = 24 * 60 * 60 * 1000; */ export function UpdateStartupPopup() { const { config } = useAppConfig(); + const isMobile = useIsMobile(); const { appVersion } = useFrontendVersionInfo(config?.appVersion); // The version to compare against the latest. Prefer the frontend version @@ -39,10 +42,12 @@ export function UpdateStartupPopup() { const [showModal, setShowModal] = useState(false); const hasChecked = useRef(false); + const allowed = isUpdatePopupAllowed(config, isMobile); + useEffect(() => { if (hasChecked.current) return; if (!currentVersion) return; - // Don't even schedule the timer until we have a version to compare. + if (!allowed) return; hasChecked.current = true; const timer = setTimeout(async () => { @@ -79,12 +84,14 @@ export function UpdateStartupPopup() { return () => clearTimeout(timer); }, [ + allowed, currentVersion, config?.machineType, config?.activeSecurity, config?.license, ]); + if (!allowed) return null; if (!updateSummary || !currentVersion) return null; const machineInfo = { diff --git a/frontend/editor/src/core/components/shared/updatePopupGate.test.ts b/frontend/editor/src/core/components/shared/updatePopupGate.test.ts new file mode 100644 index 0000000000..b42bdf55b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/updatePopupGate.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import type { AppConfig } from "@app/types/appConfig"; +import { isUpdatePopupAllowed } from "@app/components/shared/updatePopupGate"; + +describe("isUpdatePopupAllowed", () => { + it("returns false until config has loaded", () => { + expect(isUpdatePopupAllowed(null, false)).toBe(false); + }); + + it("shows on desktop when the backend allows it", () => { + expect( + isUpdatePopupAllowed({ shouldShowUpdate: true } as AppConfig, false), + ).toBe(true); + }); + + it("never shows on a mobile / narrow viewport", () => { + expect( + isUpdatePopupAllowed({ shouldShowUpdate: true } as AppConfig, true), + ).toBe(false); + }); + + it("hides when the backend says not to (non-admin / showUpdate disabled)", () => { + expect( + isUpdatePopupAllowed({ shouldShowUpdate: false } as AppConfig, false), + ).toBe(false); + }); + + it("fails closed when the backend omits the decision (e.g. 401 fallback)", () => { + expect( + isUpdatePopupAllowed({ enableLogin: true } as AppConfig, false), + ).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/components/shared/updatePopupGate.ts b/frontend/editor/src/core/components/shared/updatePopupGate.ts new file mode 100644 index 0000000000..c086e7b664 --- /dev/null +++ b/frontend/editor/src/core/components/shared/updatePopupGate.ts @@ -0,0 +1,10 @@ +import type { AppConfig } from "@app/types/appConfig"; + +export function isUpdatePopupAllowed( + config: AppConfig | null, + isMobile: boolean, +): boolean { + if (!config) return false; + if (isMobile) return false; + return config.shouldShowUpdate === true; +} diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 55d73d7e0f..c73faebd8c 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -13,6 +13,7 @@ export interface AppConfig { enableOAuth?: boolean; enableSaml?: boolean; isAdmin?: boolean; + shouldShowUpdate?: boolean; enableAlphaFunctionality?: boolean; enableAnalytics?: boolean | null; enablePosthog?: boolean | null; diff --git a/frontend/editor/src/saas/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/saas/components/shared/UpdateStartupPopup.tsx new file mode 100644 index 0000000000..1a53359afe --- /dev/null +++ b/frontend/editor/src/saas/components/shared/UpdateStartupPopup.tsx @@ -0,0 +1,5 @@ +export function UpdateStartupPopup() { + return null; +} + +export default UpdateStartupPopup; From 5be9a0e1df3ad99b0a2f1f22f88b5033362e7701 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:12:48 +0100 Subject: [PATCH 5/9] fix desktop bundles (#6773) # Description of Changes Changes - Use 127.0.0.1 instead of localhost for the local backend. The bundled backend starts on a random port and binds the IPv4 wildcard, but the frontend health-checked http://localhost:{port}. On macOS (and some Linux) localhost resolves to IPv6 ::1 first, so the connection is refused and every backend-dependent tool shows "backend offline" even though the backend started fine. Switched getBackendUrl() and the health-check URL to the 127.0.0.1 loopback literal (already in the Tauri HTTP capability allowlist, and what the OAuth loopback server already uses). Client-side tools were unaffected, which matches the reports. - Fail the desktop build when the bundled JRE is older than the app JAR. The app JAR is compiled for Java 25, but the bundle could ship an older runtime/jre (jlink:runtime short-circuits on an existing runtime, and nothing checked its version), producing UnsupportedClassVersionError at launch so the backend never starts. Added a jlink:verify task that reads the jlink release file and fails the build if the bundled JRE major is below REQUIRED_JAVA (25, kept in sync with build.gradle modernJavaVersion). It runs after the runtime is staged - including the short-circuit reuse path that lets a stale JRE slip through. Cross-platform Node script, no new dependencies. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .taskfiles/desktop.yml | 27 +++++++++++- .../editor/scripts/verify-bundled-jre.mjs | 42 +++++++++++++++++++ .../editor/src-tauri/src/commands/backend.rs | 2 +- .../desktop/services/tauriBackendService.ts | 10 +++-- 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 frontend/editor/scripts/verify-bundled-jre.mjs diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 0b37df9f6f..239602beb5 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -5,6 +5,11 @@ vars: # NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink" + # Minimum Java major the bundled JRE must be. Keep in sync with build.gradle + # `modernJavaVersion` - the app JAR is compiled for this, so an older runtime + # fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify. + REQUIRED_JAVA: "25" + # Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all'). JPDFIUM_PLATFORMS: sh: | @@ -102,6 +107,20 @@ tasks: jlink: desc: "Build backend JAR and create JLink runtime for Tauri" deps: [jlink:jar, jlink:runtime] + # Runs after the runtime is in place. Lives here (not in jlink:runtime's + # cmds) so it still fires when jlink:runtime short-circuits on its `status:` + # check and reuses an existing runtime/jre - that reuse path is exactly how + # a stale, too-old JRE slips through. + cmds: + - task: jlink:verify + + jlink:verify: + desc: "Fail the build if the bundled JRE is older than the app JAR requires" + dir: editor + env: + REQUIRED_JAVA: "{{.REQUIRED_JAVA}}" + cmds: + - node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release jlink:jar: desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)" @@ -127,9 +146,13 @@ tasks: cmds: - rm -rf runtime/jre - mkdir -p runtime + # Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build + # uses. Bare `jlink` on PATH can resolve to an older system Java (the + # ubuntu runner ships Java 11), producing a runtime jlink:verify rejects. - | - JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)" - jlink \ + JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink" + JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)" + "$JLINK" \ --add-modules {{.JLINK_MODULES}} \ --strip-debug \ --compress="$JLINK_COMPRESS" \ diff --git a/frontend/editor/scripts/verify-bundled-jre.mjs b/frontend/editor/scripts/verify-bundled-jre.mjs new file mode 100644 index 0000000000..b826a0292d --- /dev/null +++ b/frontend/editor/scripts/verify-bundled-jre.mjs @@ -0,0 +1,42 @@ +// Fail the desktop build if the bundled jlink runtime is older than the Java +// version the app JAR is compiled for. A too-old runtime ships happily today +// (the jlink task short-circuits on a stale runtime/jre, and nothing checks the +// version), then dies at launch with UnsupportedClassVersionError -> the +// backend never starts and every tool shows "backend offline". +// +// Reads JAVA_VERSION from the jlink `release` file (always present in a jlink +// output) so it needs no shell and behaves identically on Windows/macOS/Linux. +// +// Required major comes from REQUIRED_JAVA (wired from .taskfiles/desktop.yml, +// which mirrors build.gradle `modernJavaVersion`). Keep them in sync. +import { readFileSync } from "node:fs"; + +const required = Number(process.env.REQUIRED_JAVA ?? "25"); +const releasePath = process.argv[2] ?? "runtime/jre/release"; + +let raw; +try { + raw = readFileSync(releasePath, "utf8"); +} catch (err) { + console.error( + `FATAL: cannot read bundled JRE release file at "${releasePath}": ${err.message}. ` + + `Is the runtime built? Run 'task desktop:jlink'.`, + ); + process.exit(1); +} + +const match = raw.match(/JAVA_VERSION="?(\d+)/); +const major = match ? Number(match[1]) : 0; + +console.log( + `Bundled JRE major: ${major || "unknown"} (required >= ${required})`, +); + +if (!major || major < required) { + console.error( + `FATAL: bundled runtime/jre is Java ${major || "unknown"} but the app JAR requires ` + + `Java ${required}. Run 'task desktop:jlink:clean' and rebuild with JDK ${required} active ` + + `(check 'java -version' / JAVA_HOME).`, + ); + process.exit(1); +} diff --git a/frontend/editor/src-tauri/src/commands/backend.rs b/frontend/editor/src-tauri/src/commands/backend.rs index 8d5a9764f1..00dc1bb486 100644 --- a/frontend/editor/src-tauri/src/commands/backend.rs +++ b/frontend/editor/src-tauri/src/commands/backend.rs @@ -308,7 +308,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver void): () => void { @@ -227,7 +231,7 @@ export class TauriBackendService { }); } - /** Always checks the local bundled backend at localhost:{port}. */ + /** Always checks the local bundled backend at 127.0.0.1:{port}. */ async checkBackendHealth(): Promise { if (!this.backendStarted) { console.debug("[TauriBackendService] Health check: backend not started"); @@ -241,7 +245,7 @@ export class TauriBackendService { return false; } - const configUrl = `http://localhost:${this.backendPort}/api/v1/config/app-config`; + const configUrl = `http://127.0.0.1:${this.backendPort}/api/v1/config/app-config`; console.debug( `[TauriBackendService] Checking local backend health at: ${configUrl}`, ); From f715a73f1b37d7cc29b54388717d21eedb99eef1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:42:56 +0100 Subject: [PATCH 6/9] build(deps): bump astral-sh/setup-uv from 8.1.0 to 8.2.0 + translation files (#6748) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Signed-off-by: dependabot[bot] --- .github/workflows/ai-engine.yml | 2 +- .github/workflows/pre_commit.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- frontend/editor/public/locales/ar-AR/translation.toml | 5 +++-- frontend/editor/public/locales/az-AZ/translation.toml | 5 +++-- frontend/editor/public/locales/bg-BG/translation.toml | 5 +++-- frontend/editor/public/locales/bo-CN/translation.toml | 5 +++-- frontend/editor/public/locales/ca-CA/translation.toml | 5 +++-- frontend/editor/public/locales/cs-CZ/translation.toml | 5 +++-- frontend/editor/public/locales/da-DK/translation.toml | 5 +++-- frontend/editor/public/locales/de-DE/translation.toml | 5 +++-- frontend/editor/public/locales/el-GR/translation.toml | 5 +++-- frontend/editor/public/locales/en-GB/translation.toml | 5 +++-- frontend/editor/public/locales/es-ES/translation.toml | 5 +++-- frontend/editor/public/locales/eu-ES/translation.toml | 5 +++-- frontend/editor/public/locales/fa-IR/translation.toml | 5 +++-- frontend/editor/public/locales/fr-FR/translation.toml | 5 +++-- frontend/editor/public/locales/ga-IE/translation.toml | 5 +++-- frontend/editor/public/locales/hi-IN/translation.toml | 5 +++-- frontend/editor/public/locales/hr-HR/translation.toml | 5 +++-- frontend/editor/public/locales/hu-HU/translation.toml | 5 +++-- frontend/editor/public/locales/id-ID/translation.toml | 5 +++-- frontend/editor/public/locales/it-IT/translation.toml | 5 +++-- frontend/editor/public/locales/ja-JP/translation.toml | 5 +++-- frontend/editor/public/locales/ko-KR/translation.toml | 5 +++-- frontend/editor/public/locales/ml-ML/translation.toml | 5 +++-- frontend/editor/public/locales/nl-NL/translation.toml | 5 +++-- frontend/editor/public/locales/no-NB/translation.toml | 5 +++-- frontend/editor/public/locales/pl-PL/translation.toml | 5 +++-- frontend/editor/public/locales/pt-BR/translation.toml | 5 +++-- frontend/editor/public/locales/pt-PT/translation.toml | 5 +++-- frontend/editor/public/locales/ro-RO/translation.toml | 5 +++-- frontend/editor/public/locales/ru-RU/translation.toml | 5 +++-- frontend/editor/public/locales/sk-SK/translation.toml | 5 +++-- frontend/editor/public/locales/sl-SI/translation.toml | 5 +++-- frontend/editor/public/locales/sr-LATN-RS/translation.toml | 5 +++-- frontend/editor/public/locales/sv-SE/translation.toml | 5 +++-- frontend/editor/public/locales/th-TH/translation.toml | 5 +++-- frontend/editor/public/locales/tr-TR/translation.toml | 5 +++-- frontend/editor/public/locales/uk-UA/translation.toml | 5 +++-- frontend/editor/public/locales/vi-VN/translation.toml | 5 +++-- frontend/editor/public/locales/zh-BO/translation.toml | 5 +++-- frontend/editor/public/locales/zh-CN/translation.toml | 5 +++-- frontend/editor/public/locales/zh-TW/translation.toml | 5 +++-- 44 files changed, 126 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 03b172c4c3..223490f45f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 85d2efa996..37c31b7be3 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 4647442d4f..956453192b 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -61,7 +61,7 @@ jobs: pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index aa00593cad..eff6d7072f 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "إغلاق" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "إغلاق" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "كل الإصدارات" breaking = "تغييرات كاسرة" breakingChanges = "تغييرات كاسرة للتوافق" breakingChangesDefault = "يحتوي هذا الإصدار على تغييرات كاسرة للتوافق." -breakingChangesDetected = "تم اكتشاف تغييرات كاسرة للتوافق" -breakingChangesMessage = "تحتوي بعض الإصدارات على تغييرات كاسرة للتوافق. يرجى مراجعة أدلة الترحيل أدناه قبل التحديث." close = "إغلاق" closeModal = "إغلاق نافذة التحديث" current = "الإصدار الحالي" @@ -8606,6 +8606,7 @@ disableByAdmin = "تعطيل MFA" [workspace.people.roleDescriptions] admin = "يمكنه إدارة الإعدادات ودعوة الأعضاء مع صلاحيات إدارية كاملة." +currentRole = "الدور المعيّن الحالي." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f32846d840..c4401197dc 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Bağla" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Bağla" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Bütün buraxılışlar" breaking = "Uyğunluğu pozan" breakingChanges = "Uyğunluğu pozan dəyişikliklər" breakingChangesDefault = "Bu versiya uyğunluğu pozan dəyişikliklər ehtiva edir." -breakingChangesDetected = "Uyğunluğu pozan dəyişikliklər aşkarlandı" -breakingChangesMessage = "Bəzi versiyalar uyğunluğu pozan dəyişikliklər ehtiva edir. Yeniləməzdən əvvəl aşağıdakı miqrasiya bələdçilərini nəzərdən keçirin." close = "Bağla" closeModal = "Yeniləmə pəncərəsini bağla" current = "Cari versiya" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA-nı deaktiv et" [workspace.people.roleDescriptions] admin = "Ayarları idarə edə və üzvləri dəvət edə bilər, tam inzibati girişə malikdir." +currentRole = "Hazırda təyin olunmuş rol." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index ecc16ab0d1..2d0b6558ff 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Затвори" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Затвори" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Всички издания" breaking = "Несъвместими промени" breakingChanges = "Несъвместими промени" breakingChangesDefault = "Тази версия съдържа несъвместими промени." -breakingChangesDetected = "Открити несъвместими промени" -breakingChangesMessage = "Някои версии съдържат несъвместими промени. Прегледайте ръководствата за миграция по-долу, преди да актуализирате." close = "Затвори" closeModal = "Затвори прозореца за актуализация" current = "Текуща версия" @@ -8606,6 +8606,7 @@ disableByAdmin = "Деактивиране на MFA" [workspace.people.roleDescriptions] admin = "Може да управлява настройки и да кани членове, с пълен административен достъп." +currentRole = "Текущо назначена роля." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 35b531799a..3e86529d8b 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "སྒོ་བསྡམས" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "སྒོ་བསྡམས" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "གསར་བརྗེ་ཚང་མ" breaking = "མཚམས་སྐྱེད" breakingChanges = "འགྱུར་ལྡོག་འགལ་བ།" breakingChangesDefault = "ཐོན་རིམ་འདིའི་ནང་ལ་འགྱུར་ལྡོག་གི་བསྒྱུར་བཅོས་ཡོད།" -breakingChangesDetected = "བརྡབ་པའི་འགྱུར་བ།" -breakingChangesMessage = "ཐོན་རིམ་ཁ་ཤས་ལ་བསྒྱུར་བཅོས་ཆག་པ་ཡོད། གསར་བརྗེ་མ་བྱས་གོང་གཤམ་གྱི་གནས་སྤོ་ལམ་སྟོན་ལ་ཞིབ་བཤེར་གནང་རོགས།" close = "གཏན་པ" closeModal = "གསར་སྒྱུར་སྒོ་ཁ་རྒྱག" current = "ད་ལྟའི་ཐོན་རིམ།" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA སྤྱོད་སྒོ་བརྒྱབ" [workspace.people.roleDescriptions] admin = "སྒྲིག་སྟངས་འཛིན་སྐྱོང་བྱེད་ཐུབ་པ་དང་ཚོགས་མི་གདན་འདྲེན་ཞུ་ཐུབ།" +currentRole = "ད་ལྟའི་བསྐོས་པའི་ལས་འགན།" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 66e14d3096..13ba50a501 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Tanca" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Tanca" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Totes les versions" breaking = "Canvis incompatibles" breakingChanges = "Canvis incompatibles" breakingChangesDefault = "Aquesta versió conté canvis incompatibles." -breakingChangesDetected = "S'han detectat canvis incompatibles" -breakingChangesMessage = "Algunes versions contenen canvis incompatibles. Revisa les guies de migració abans d'actualitzar." close = "Tanca" closeModal = "Tanca la finestra d'actualització" current = "Versió actual" @@ -8606,6 +8606,7 @@ disableByAdmin = "Desactiva l'MFA" [workspace.people.roleDescriptions] admin = "Pot gestionar paràmetres i convidar membres, amb accés administratiu complet." +currentRole = "Rol assignat actual." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 6f4d5a500d..a58bb5a575 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zavřít" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zavřít" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Všechny verze" breaking = "Zpětně nekompatibilní" breakingChanges = "Nekompatibilní změny" breakingChangesDefault = "Tato verze obsahuje nekompatibilní změny." -breakingChangesDetected = "Zjištěny nekompatibilní změny" -breakingChangesMessage = "Některé verze obsahují nekompatibilní změny. Před aktualizací si prosím prostudujte migrační průvodce níže." close = "Zavřít" closeModal = "Zavřít okno aktualizace" current = "Aktuální verze" @@ -8606,6 +8606,7 @@ disableByAdmin = "Zakázat MFA" [workspace.people.roleDescriptions] admin = "Může spravovat nastavení a zvát členy, s plným administrativním přístupem." +currentRole = "Aktuálně přiřazená role." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index 030ef59b23..24243d51a6 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Luk" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Luk" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Alle udgivelser" breaking = "Breaking" breakingChanges = "Inkompatible ændringer" breakingChangesDefault = "Denne version indeholder inkompatible ændringer." -breakingChangesDetected = "Inkompatible ændringer registreret" -breakingChangesMessage = "Nogle versioner indeholder inkompatible ændringer. Gennemgå venligst migrationsvejledningerne nedenfor før opdatering." close = "Luk" closeModal = "Luk opdateringsvindue" current = "Nuværende version" @@ -8606,6 +8606,7 @@ disableByAdmin = "Deaktivér MFA" [workspace.people.roleDescriptions] admin = "Kan administrere indstillinger og invitere medlemmer med fuld administrativ adgang." +currentRole = "Nuværende tildelt rolle." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index a3fe32fdd9..55a5022189 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Schließen" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Schließen" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Alle Versionen" breaking = "Breaking" breakingChanges = "Inkompatible Änderungen" breakingChangesDefault = "Diese Version enthält inkompatible Änderungen." -breakingChangesDetected = "Inkompatible Änderungen erkannt" -breakingChangesMessage = "Einige Versionen enthalten inkompatible Änderungen. Bitte prüfen Sie vor dem Update die folgenden Migrationshinweise." close = "Schließen" closeModal = "Update-Dialog schließen" current = "Aktuelle Version" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA deaktivieren" [workspace.people.roleDescriptions] admin = "Kann Einstellungen verwalten und Mitglieder einladen, mit vollständigem administrativem Zugriff." +currentRole = "Aktuell zugewiesene Rolle." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 174ab4cd6e..088d5df7b7 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Κλείσιμο" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Κλείσιμο" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Όλες οι εκδόσεις" breaking = "Ασυμβατό" breakingChanges = "Ασύμβατες αλλαγές" breakingChangesDefault = "Αυτή η έκδοση περιέχει ασύμβατες αλλαγές." -breakingChangesDetected = "Εντοπίστηκαν ασύμβατες αλλαγές" -breakingChangesMessage = "Ορισμένες εκδόσεις περιέχουν ασύμβατες αλλαγές. Ελέγξτε τους οδηγούς μετεγκατάστασης παρακάτω πριν ενημερώσετε." close = "Κλείσιμο" closeModal = "Κλείσιμο παραθύρου ενημέρωσης" current = "Τρέχουσα έκδοση" @@ -8606,6 +8606,7 @@ disableByAdmin = "Απενεργοποίηση MFA" [workspace.people.roleDescriptions] admin = "Μπορεί να διαχειρίζεται ρυθμίσεις και να προσκαλεί μέλη, με πλήρη δικαιώματα διαχείρισης." +currentRole = "Τρέχων ανατεθειμένος ρόλος." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 0ac9979533..7a97f4e4a9 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Close" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Close" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "All Releases" breaking = "Breaking" breakingChanges = "Breaking Changes" breakingChangesDefault = "This version contains breaking changes." -breakingChangesDetected = "Breaking Changes Detected" -breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating." close = "Close" closeModal = "Close update modal" current = "Current Version" @@ -8606,6 +8606,7 @@ disableByAdmin = "Disable MFA" [workspace.people.roleDescriptions] admin = "Can manage settings and invite users, with full administrative access." +currentRole = "Current assigned role." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index a487e00a9d..ecac7c9ef1 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Cerrar" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Cerrar" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Todas las versiones" breaking = "Incompatible" breakingChanges = "Cambios incompatibles" breakingChangesDefault = "Esta versión contiene cambios incompatibles." -breakingChangesDetected = "Se detectaron cambios incompatibles" -breakingChangesMessage = "Algunas versiones contienen cambios incompatibles. Revise las guías de migración a continuación antes de actualizar." close = "Cerrar" closeModal = "Cerrar modal de actualización" current = "Versión actual" @@ -8606,6 +8606,7 @@ disableByAdmin = "Deshabilitar MFA" [workspace.people.roleDescriptions] admin = "Puede gestionar ajustes e invitar miembros, con acceso administrativo completo." +currentRole = "Rol asignado actual." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index 714a3fa815..600f192bfe 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Itxi" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Itxi" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Argitalpen guztiak" breaking = "Aldaketa haustzaileak" breakingChanges = "Aldaketa haustzaileak" breakingChangesDefault = "Bertsio honek aldaketa haustzaileak ditu." -breakingChangesDetected = "Bateragarritasuna hausten duten aldaketak atzeman dira" -breakingChangesMessage = "Bertsio batzuek bateragarritasuna hausten duten aldaketak dituzte. Irakurri beheko migrazio-gidak eguneratu aurretik." close = "Itxi" closeModal = "Itxi eguneratze-modal" current = "Uneko bertsioa" @@ -8606,6 +8606,7 @@ disableByAdmin = "Desgaitu MFA" [workspace.people.roleDescriptions] admin = "Ezarpenak kudeatu eta kideak gonbidatu ditzake, administrazio-eskumen osoekin." +currentRole = "Uneko esleitutako rola." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 604fc8138b..ed0d215be9 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "بستن" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "בستن" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "همهٔ نسخه‌ها" breaking = "تغییرات مخرب" breakingChanges = "تغییرات ناسازگار" breakingChangesDefault = "این نسخه شامل تغییرات ناسازگار است." -breakingChangesDetected = "تغییرات ناسازگار شناسایی شد" -breakingChangesMessage = "برخی نسخه‌ها شامل تغییرات ناسازگارند. لطفاً پیش از به‌روزرسانی، راهنماهای مهاجرت زیر را مرور کنید." close = "بستن" closeModal = "بستن پنجرهٔ به‌روزرسانی" current = "نسخه فعلی" @@ -8606,6 +8606,7 @@ disableByAdmin = "غیرفعال کردن MFA" [workspace.people.roleDescriptions] admin = "می‌تواند تنظیمات را مدیریت و اعضا را دعوت کند، با دسترسی کامل مدیریتی." +currentRole = "نقش تخصیص‌یافتهٔ فعلی." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 46cec92e4b..d4def7ab17 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Fermer" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Fermer" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Toutes les versions" breaking = "Incompatible" breakingChanges = "Modifications majeures" breakingChangesDefault = "Cette version contient des modifications majeures." -breakingChangesDetected = "Modifications majeures détectées" -breakingChangesMessage = "Certaines versions contiennent des modifications majeures. Veuillez consulter les guides de migration ci-dessous avant de mettre à jour." close = "Fermer" closeModal = "Fermer la fenêtre de mise à jour" current = "Version actuelle" @@ -8606,6 +8606,7 @@ disableByAdmin = "Désactiver la MFA" [workspace.people.roleDescriptions] admin = "Peut gérer les paramètres et inviter des membres, avec un accès administratif complet." +currentRole = "Rôle actuellement attribué." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index f2ee950715..766a0eee14 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Dún" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Dún" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Gach Eisiúint" breaking = "Briseadh comhoiriúnachta" breakingChanges = "Athruithe Bristeacha" breakingChangesDefault = "Tá athruithe bristeacha sa leagan seo." -breakingChangesDetected = "Aimsíodh Athruithe Bristeacha" -breakingChangesMessage = "Tá athruithe bristeacha i roinnt leaganacha. Déan athbhreithniú ar na treoracha imirce thíos sula nuashonraíonn tú." close = "Dún" closeModal = "Dún an modail nuashonraithe" current = "Leagan Reatha" @@ -8606,6 +8606,7 @@ disableByAdmin = "Díchumasaigh MFA" [workspace.people.roleDescriptions] admin = "Is féidir socruithe a bhainistiú agus baill a thabhairt isteach, le rochtain riaracháin iomlán." +currentRole = "Ról sannta faoi láthair." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index 88f76da259..6ff8a260a2 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "बंद करें" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "बंद करें" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "सभी रिलीज़" breaking = "ब्रेकिंग" breakingChanges = "असंगत परिवर्तन" breakingChangesDefault = "इस संस्करण में असंगत परिवर्तन शामिल हैं।" -breakingChangesDetected = "असंगत परिवर्तन मिले" -breakingChangesMessage = "कुछ संस्करणों में असंगत परिवर्तन हैं। अपडेट से पहले नीचे दिए गए माइग्रेशन गाइड्स देखें।" close = "बंद करें" closeModal = "अपडेट मोडल बंद करें" current = "वर्तमान संस्करण" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA अक्षम करें" [workspace.people.roleDescriptions] admin = "सेटिंग्स और आमंत्रण प्रबंधित कर सकता है, पूर्ण प्रशासनिक पहुंच के साथ।" +currentRole = "वर्तमान सौंपी गई भूमिका।" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 5627ddc12f..e485d369f9 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zatvori" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zatvori" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Sva izdanja" breaking = "Nekompatibilno" breakingChanges = "Nekompatibilne promjene" breakingChangesDefault = "Ova verzija sadrži nekompatibilne promjene." -breakingChangesDetected = "Otkrivene nekompatibilne promjene" -breakingChangesMessage = "Neke verzije sadrže nekompatibilne promjene. Prije ažuriranja pregledajte vodiče za migraciju u nastavku." close = "Zatvori" closeModal = "Zatvori prozor ažuriranja" current = "Trenutačna verzija" @@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogući MFA" [workspace.people.roleDescriptions] admin = "Može upravljati postavkama i pozivati članove, s punim administrativnim pristupom." +currentRole = "Trenutno dodijeljena uloga." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index b84c0989b6..bc89d3f30b 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Bezárás" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Bezárás" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Összes kiadás" breaking = "Kompatibilitást törő" breakingChanges = "Inkompatibilis változások" breakingChangesDefault = "Ez a verzió inkompatibilis változásokat tartalmaz." -breakingChangesDetected = "Inkompatibilis változások észlelve" -breakingChangesMessage = "Egyes verziók inkompatibilis változásokat tartalmaznak. Frissítés előtt tekintse át az alábbi átállási útmutatókat." close = "Bezárás" closeModal = "Frissítési ablak bezárása" current = "Jelenlegi verzió" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA letiltása" [workspace.people.roleDescriptions] admin = "Kezelheti a beállításokat és meghívhat tagokat, teljes adminisztratív hozzáféréssel." +currentRole = "Jelenleg hozzárendelt szerepkör." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index dec50a493c..5b020b184c 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Tutup" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Tutup" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Semua Rilis" breaking = "Perubahan besar" breakingChanges = "Perubahan Signifikan" breakingChangesDefault = "Versi ini berisi perubahan yang memutus kompatibilitas." -breakingChangesDetected = "Perubahan Signifikan Terdeteksi" -breakingChangesMessage = "Beberapa versi berisi perubahan yang memutus kompatibilitas. Harap tinjau panduan migrasi di bawah sebelum memperbarui." close = "Tutup" closeModal = "Tutup modal pembaruan" current = "Versi Saat Ini" @@ -8606,6 +8606,7 @@ disableByAdmin = "Nonaktifkan MFA" [workspace.people.roleDescriptions] admin = "Dapat mengelola pengaturan dan mengundang anggota, dengan akses administratif penuh." +currentRole = "Peran yang ditetapkan saat ini." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index 74162033ec..ab62ce3e28 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Chiudi" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Chiudi" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Tutti i rilasci" breaking = "Breaking" breakingChanges = "Modifiche incompatibili" breakingChangesDefault = "Questa versione contiene modifiche incompatibili." -breakingChangesDetected = "Rilevate modifiche incompatibili" -breakingChangesMessage = "Alcune versioni contengono modifiche incompatibili. Consulta le guide di migrazione qui sotto prima di aggiornare." close = "Chiudi" closeModal = "Chiudi finestra aggiornamento" current = "Versione attuale" @@ -8606,6 +8606,7 @@ disableByAdmin = "Disabilita MFA" [workspace.people.roleDescriptions] admin = "Può gestire impostazioni e invitare membri, con pieno accesso amministrativo." +currentRole = "Ruolo attualmente assegnato." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index 9aa0f405a7..c2b17206f5 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "閉じる" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "閉じる" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "すべてのリリース" breaking = "重大変更" breakingChanges = "互換性のない変更" breakingChangesDefault = "このバージョンには互換性のない変更が含まれます。" -breakingChangesDetected = "互換性のない変更を検出" -breakingChangesMessage = "一部のバージョンには互換性のない変更が含まれます。更新前に以下の移行ガイドを確認してください。" close = "閉じる" closeModal = "更新モーダルを閉じる" current = "現在のバージョン" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA を無効化" [workspace.people.roleDescriptions] admin = "設定の管理とメンバーの招待が可能。管理者としてのフルアクセス権があります。" +currentRole = "現在割り当てられているロール。" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 78c3a1adb6..84485a0649 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "닫기" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "닫기" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "모든 릴리스" breaking = "중대한 변경" breakingChanges = "호환성 파괴 변경" breakingChangesDefault = "이 버전에는 호환성 파괴 변경이 포함되어 있습니다." -breakingChangesDetected = "호환성 파괴 변경 감지됨" -breakingChangesMessage = "일부 버전에 호환성 파괴 변경이 포함되어 있습니다. 업데이트 전에 아래 마이그레이션 가이드를 검토하세요." close = "닫기" closeModal = "업데이트 모달 닫기" current = "현재 버전" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA 비활성화" [workspace.people.roleDescriptions] admin = "설정을 관리하고 멤버를 초대할 수 있으며, 전체 관리자 권한을 가집니다." +currentRole = "현재 할당된 역할입니다." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index b1be7ad7bb..6a1f1c2ed0 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "അടയ്ക്കുക" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "അടയ്ക്കുക" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "എല്ലാ റിലീസുകളും" breaking = "ബ്രേക്കിംഗ്" breakingChanges = "ബ്രേക്കിംഗ് മാറ്റങ്ങൾ" breakingChangesDefault = "ഈ പതിപ്പിൽ Breaking Changes ഉണ്ട്." -breakingChangesDetected = "Breaking Changes കണ്ടെത്തി" -breakingChangesMessage = "ചില പതിപ്പുകളിൽ Breaking Changes ഉൾപ്പെടുന്നു. അപ്‌ഡേറ്റ് ചെയ്യുന്നതിന് മുമ്പ് താഴെയുള്ള മൈഗ്രേഷൻ ഗൈഡുകൾ പരിശോധിക്കുക." close = "അടയ്ക്കുക" closeModal = "അപ്‌ഡേറ്റ് മോഡൽ അടയ്ക്കുക" current = "ഇപ്പോഴത്തെ പതിപ്പ്" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA പ്രവർത്തനരഹിതമാക്കു [workspace.people.roleDescriptions] admin = "പൂർണ്ണ അഡ്മിനിസ്ട്രേറ്റീവ് ആക്സസോടുകൂടി ക്രമീകരണങ്ങളും അംഗ ക്ഷണങ്ങളും നിയന്ത്രിക്കാം." +currentRole = "നിലവിൽ നിയോഗിച്ചിരിക്കുന്ന പദവി." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index ba60e20379..840344bef7 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Sluiten" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Sluiten" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Alle releases" breaking = "Breaking" breakingChanges = "Incompatibele wijzigingen" breakingChangesDefault = "Deze versie bevat incompatibele wijzigingen." -breakingChangesDetected = "Incompatibele wijzigingen gedetecteerd" -breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat u update." close = "Sluiten" closeModal = "Updatevenster sluiten" current = "Huidige versie" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA uitschakelen" [workspace.people.roleDescriptions] admin = "Kan instellingen beheren en leden uitnodigen, met volledige beheerdersrechten." +currentRole = "Momenteel toegewezen rol." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 43061a40ba..aa647fb6a2 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Lukk" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Lukk" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Alle utgivelser" breaking = "Brytende" breakingChanges = "Inkompatible endringer" breakingChangesDefault = "Denne versjonen inneholder inkompatible endringer." -breakingChangesDetected = "Inkompatible endringer oppdaget" -breakingChangesMessage = "Noen versjoner inneholder inkompatible endringer. Les migreringsveiledningene nedenfor før du oppdaterer." close = "Lukk" closeModal = "Lukk oppdateringsvindu" current = "Nåværende versjon" @@ -8606,6 +8606,7 @@ disableByAdmin = "Deaktiver MFA" [workspace.people.roleDescriptions] admin = "Kan administrere innstillinger og invitere medlemmer, med full administrativ tilgang." +currentRole = "Gjeldende tildelt rolle." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 4e00a8852e..8b546bd487 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zamknij" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zamknij" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Wszystkie wydania" breaking = "Niekompatybilne zmiany" breakingChanges = "Zmiany niekompatybilne" breakingChangesDefault = "Ta wersja zawiera zmiany niekompatybilne." -breakingChangesDetected = "Wykryto zmiany niekompatybilne" -breakingChangesMessage = "Niektóre wersje zawierają zmiany niekompatybilne. Przejrzyj poniższe przewodniki migracji przed aktualizacją." close = "Zamknij" closeModal = "Zamknij okno aktualizacji" current = "Bieżąca wersja" @@ -8606,6 +8606,7 @@ disableByAdmin = "Wyłącz MFA" [workspace.people.roleDescriptions] admin = "Może zarządzać ustawieniami i zapraszać członków, z pełnym dostępem administracyjnym." +currentRole = "Aktualnie przypisana rola." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index f64c4b4418..2d65aea50c 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Fechar" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Fechar" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "All Releases" breaking = "Breaking" breakingChanges = "Breaking Changes" breakingChangesDefault = "This version contains breaking changes." -breakingChangesDetected = "Breaking Changes Detected" -breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating." close = "Close" closeModal = "Close update modal" current = "Current Version" @@ -8606,6 +8606,7 @@ disableByAdmin = "Disable MFA" [workspace.people.roleDescriptions] admin = "Can manage settings and invite members, with full administrative access." +currentRole = "Função atribuída atualmente." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 4142e5d6f2..0852299ef1 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Fechar" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Fechar" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Todas as versões" breaking = "Alterações incompatíveis" breakingChanges = "Alterações incompatíveis" breakingChangesDefault = "Esta versão contém alterações incompatíveis." -breakingChangesDetected = "Alterações incompatíveis detetadas" -breakingChangesMessage = "Algumas versões contêm alterações incompatíveis. Consulte os guias de migração abaixo antes de atualizar." close = "Fechar" closeModal = "Fechar modal de atualização" current = "Versão atual" @@ -8606,6 +8606,7 @@ disableByAdmin = "Desativar MFA" [workspace.people.roleDescriptions] admin = "Pode gerir definições e convidar membros, com acesso administrativo total." +currentRole = "Função atualmente atribuída." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 131d3cbb30..cba99f3e78 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Închide" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Închide" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Toate versiunile" breaking = "Schimbări majore" breakingChanges = "Modificări incompatibile" breakingChangesDefault = "Această versiune conține modificări incompatibile." -breakingChangesDetected = "Modificări incompatibile detectate" -breakingChangesMessage = "Unele versiuni conțin modificări incompatibile. Consultă ghidurile de migrare de mai jos înainte de a actualiza." close = "Închide" closeModal = "Închideți fereastra de actualizare" current = "Versiune curentă" @@ -8606,6 +8606,7 @@ disableByAdmin = "Dezactivați MFA" [workspace.people.roleDescriptions] admin = "Poate gestiona setările și invita membri, cu acces administrativ complet." +currentRole = "Rolul atribuit în prezent." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index f8e88db0ae..dc2ca75f65 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Закрыть" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Закрыть" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Все релизы" breaking = "Несовместимые" breakingChanges = "Ломающие изменения" breakingChangesDefault = "Эта версия содержит ломающие изменения." -breakingChangesDetected = "Обнаружены ломающие изменения" -breakingChangesMessage = "Некоторые версии содержат ломающие изменения. Перед обновлением ознакомьтесь с руководствами по миграции ниже." close = "Закрыть" closeModal = "Закрыть окно обновления" current = "Текущая версия" @@ -8606,6 +8606,7 @@ disableByAdmin = "Отключить MFA" [workspace.people.roleDescriptions] admin = "Может управлять настройками и приглашать участников, имеет полный административный доступ." +currentRole = "Текущая назначенная роль." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 8799678114..9a86ad5e31 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zavrieť" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zavrieť" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Všetky vydania" breaking = "Nezlučiteľné" breakingChanges = "Nezlučiteľné zmeny" breakingChangesDefault = "Táto verzia obsahuje nezlučiteľné zmeny." -breakingChangesDetected = "Zistené nezlučiteľné zmeny" -breakingChangesMessage = "Niektoré verzie obsahujú nezlučiteľné zmeny. Pred aktualizáciou si pozrite migračné príručky nižšie." close = "Zavrieť" closeModal = "Zavrieť okno aktualizácie" current = "Aktuálna verzia" @@ -8606,6 +8606,7 @@ disableByAdmin = "Zakázať MFA" [workspace.people.roleDescriptions] admin = "Môže spravovať nastavenia a pozývať členov, s plným administratívnym prístupom." +currentRole = "Aktuálne priradená rola." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index df7158ec53..236f2e6d0c 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zapri" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zapri" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Vse izdaje" breaking = "Prelomne" breakingChanges = "Prelomne spremembe" breakingChangesDefault = "Ta različica vsebuje prelomne spremembe." -breakingChangesDetected = "Zaznane prelomne spremembe" -breakingChangesMessage = "Nekatere različice vsebujejo prelomne spremembe. Pred posodobitvijo preglejte spodnje vodiče za migracijo." close = "Zapri" closeModal = "Zapri okno posodobitev" current = "Trenutna različica" @@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogoči MFA" [workspace.people.roleDescriptions] admin = "Lahko upravlja nastavitve in vabi člane s polnim skrbniškim dostopom." +currentRole = "Trenutno dodeljena vloga." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 16c1b5d673..3ac1761c97 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Zatvori" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Zatvori" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Sva izdanja" breaking = "Nekompatibilne promene" breakingChanges = "Nekompatibilne promene" breakingChangesDefault = "Ova verzija sadrži nekompatibilne promene." -breakingChangesDetected = "Otkrivene nekompatibilne promene" -breakingChangesMessage = "Neke verzije sadrže nekompatibilne promene. Pregledajte vodiče za migraciju ispod pre ažuriranja." close = "Zatvori" closeModal = "Zatvori prozor ažuriranja" current = "Trenutna verzija" @@ -8606,6 +8606,7 @@ disableByAdmin = "Onemogući MFA" [workspace.people.roleDescriptions] admin = "Može da upravlja podešavanjima i poziva članove, sa punim administratorskim pristupom." +currentRole = "Trenutno dodeljena uloga." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index ae59a81591..bbad9c606d 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Stäng" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Stäng" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Alla utgåvor" breaking = "Brytande" breakingChanges = "Brytande ändringar" breakingChangesDefault = "Denna version innehåller brytande ändringar." -breakingChangesDetected = "Brytande ändringar upptäckta" -breakingChangesMessage = "Vissa versioner innehåller brytande ändringar. Läs migreringsguiderna nedan innan du uppdaterar." close = "Stäng" closeModal = "Stäng uppdateringsfönster" current = "Aktuell version" @@ -8606,6 +8606,7 @@ disableByAdmin = "Inaktivera MFA" [workspace.people.roleDescriptions] admin = "Kan hantera inställningar och bjuda in medlemmar, med full administrativ åtkomst." +currentRole = "Aktuell tilldelad roll." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index f9902d6915..836fa70c14 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "ปิด" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "ปิด" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "รุ่นทั้งหมด" breaking = "Breaking" breakingChanges = "การเปลี่ยนแปลงที่มีผลกระทบ" breakingChangesDefault = "เวอร์ชันนี้มีการเปลี่ยนแปลงที่มีผลกระทบ" -breakingChangesDetected = "พบการเปลี่ยนแปลงที่มีผลกระทบ" -breakingChangesMessage = "บางเวอร์ชันมีการเปลี่ยนแปลงที่มีผลกระทบ โปรดตรวจสอบคู่มือการย้ายด้านล่างก่อนอัปเดต" close = "ปิด" closeModal = "ปิดหน้าต่างอัปเดต" current = "เวอร์ชันปัจจุบัน" @@ -8606,6 +8606,7 @@ disableByAdmin = "ปิดใช้งาน MFA" [workspace.people.roleDescriptions] admin = "สามารถจัดการการตั้งค่าและเชิญสมาชิก พร้อมการเข้าถึงระดับผู้ดูแลทั้งหมด" +currentRole = "บทบาทที่ได้รับมอบหมายในปัจจุบัน." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index b1d0f077af..ce775c4e91 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Kapat" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Kapat" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Tüm Sürümler" breaking = "Uyumsuz" breakingChanges = "Kırıcı Değişiklikler" breakingChangesDefault = "Bu sürüm kırıcı değişiklikler içeriyor." -breakingChangesDetected = "Kırıcı Değişiklikler Tespit Edildi" -breakingChangesMessage = "Bazı sürümler kırıcı değişiklikler içerir. Lütfen güncellemeden önce aşağıdaki geçiş kılavuzlarını inceleyin." close = "Kapat" closeModal = "Güncelleme penceresini kapat" current = "Geçerli Sürüm" @@ -8606,6 +8606,7 @@ disableByAdmin = "MFA'yı devre dışı bırak" [workspace.people.roleDescriptions] admin = "Ayarları yönetebilir ve üyeleri davet edebilir; tam yönetici erişimine sahiptir." +currentRole = "Geçerli atanmış rol." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index 36f2ff8e8f..f16440b23a 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Закрити" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Закрити" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Усі релізи" breaking = "Критичні зміни" breakingChanges = "Несумісні зміни" breakingChangesDefault = "Ця версія містить несумісні зміни." -breakingChangesDetected = "Виявлено несумісні зміни" -breakingChangesMessage = "Деякі версії містять несумісні зміни. Перед оновленням перегляньте настанови з міграції нижче." close = "Закрити" closeModal = "Закрити модальне вікно оновлення" current = "Поточна версія" @@ -8606,6 +8606,7 @@ disableByAdmin = "Вимкнути MFA" [workspace.people.roleDescriptions] admin = "Може керувати налаштуваннями й запрошувати учасників, має повний адміністративний доступ." +currentRole = "Поточна призначена роль." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index ddb842e5d6..26f7d66c05 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "Đóng" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "Đóng" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "Tất cả bản phát hành" breaking = "Thay đổi phá vỡ" breakingChanges = "Thay đổi phá vỡ" breakingChangesDefault = "Phiên bản này có thay đổi phá vỡ." -breakingChangesDetected = "Phát hiện thay đổi phá vỡ" -breakingChangesMessage = "Một số phiên bản có thay đổi phá vỡ. Vui lòng xem hướng dẫn chuyển đổi bên dưới trước khi cập nhật." close = "Đóng" closeModal = "Đóng cửa sổ cập nhật" current = "Phiên bản hiện tại" @@ -8606,6 +8606,7 @@ disableByAdmin = "Tắt MFA" [workspace.people.roleDescriptions] admin = "Có thể quản lý cài đặt và mời thành viên, với quyền quản trị đầy đủ." +currentRole = "Vai trò hiện được gán." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index cfd9fafe84..a36e32209d 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "关闭" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "关闭" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "所有发布" breaking = "重大变更" breakingChanges = "破坏性变更" breakingChangesDefault = "此版本包含破坏性变更。" -breakingChangesDetected = "检测到破坏性变更" -breakingChangesMessage = "某些版本包含破坏性变更。请在更新前查看以下迁移指南。" close = "关闭" closeModal = "关闭更新窗口" current = "当前版本" @@ -8606,6 +8606,7 @@ disableByAdmin = "禁用 MFA" [workspace.people.roleDescriptions] admin = "可管理设置和邀请成员,拥有完整管理权限。" +currentRole = "当前分配的角色。" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index 7d61d3f5f5..fdfeee98d6 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "关闭" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "关闭" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "所有版本" breaking = "重大变更" breakingChanges = "重大变更" breakingChangesDefault = "此版本包含重大变更。" -breakingChangesDetected = "检测到重大变更" -breakingChangesMessage = "某些版本包含重大变更。请在更新前查看下方迁移指南。" close = "关闭" closeModal = "关闭更新窗口" current = "当前版本" @@ -8606,6 +8606,7 @@ disableByAdmin = "禁用 MFA" [workspace.people.roleDescriptions] admin = "可管理设置并邀请成员,具有完整管理权限。" +currentRole = "当前分配的角色。" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 669943500a..ed231da421 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -5854,6 +5854,7 @@ routing = "Routing" security = "Security" [policies.detail] +close = "關閉" editSettings = "Edit Settings" enforces = "Enforces" managedByOrg = "Managed by your organisation. Contact a team leader to change this policy." @@ -5907,6 +5908,7 @@ allDocTypesTitle = "All document types" back = "Back" builderDesc = "Build the sequence of tools this policy runs on each document." clear = "Clear" +close = "關閉" continue = "Continue" docTypesLabel = "Document types" edit = "Edit" @@ -7784,8 +7786,6 @@ allReleases = "所有版本" breaking = "重大變更" breakingChanges = "重大變更" breakingChangesDefault = "此版本包含重大變更。" -breakingChangesDetected = "偵測到重大變更" -breakingChangesMessage = "部分版本包含重大變更。更新前請先查看下方的遷移指南。" close = "關閉" closeModal = "關閉更新視窗" current = "目前版本" @@ -8606,6 +8606,7 @@ disableByAdmin = "停用 MFA" [workspace.people.roleDescriptions] admin = "可管理設定並邀請成員,具完整的管理存取權。" +currentRole = "目前指派的角色。" user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] From b040277220c9652d30dab4c6c18c8793a7067da8 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:44:13 +0100 Subject: [PATCH 7/9] fast-path local PDF transport and reduce chat re-renders (#6798) --- .../src-tauri/src/commands/local_proxy.rs | 147 ++++++++++++++++ frontend/editor/src-tauri/src/commands/mod.rs | 2 + frontend/editor/src-tauri/src/lib.rs | 2 + .../src/desktop/services/fileOpenService.ts | 21 ++- .../src/desktop/services/tauriHttpClient.ts | 47 ++++- .../src/desktop/services/tauriLocalProxy.ts | 162 ++++++++++++++++++ .../components/chat/ChatContext.tsx | 59 +++++-- 7 files changed, 415 insertions(+), 25 deletions(-) create mode 100644 frontend/editor/src-tauri/src/commands/local_proxy.rs create mode 100644 frontend/editor/src/desktop/services/tauriLocalProxy.ts diff --git a/frontend/editor/src-tauri/src/commands/local_proxy.rs b/frontend/editor/src-tauri/src/commands/local_proxy.rs new file mode 100644 index 0000000000..2c915d6bbc --- /dev/null +++ b/frontend/editor/src-tauri/src/commands/local_proxy.rs @@ -0,0 +1,147 @@ +//! Fast-path transport for LOCALHOST binary traffic to the bundled backend. +//! +//! `@tauri-apps/plugin-http` marshals request/response bodies across the IPC +//! bridge as a JSON array of per-byte numbers (~3.5x size bloat plus heavy GC), +//! which is painful for large PDF uploads/downloads. This command moves the +//! raw bytes across IPC as `InvokeBody::Raw` instead, carrying the request and +//! response metadata in a small length-prefixed frame alongside the untouched +//! body: +//! +//! ```text +//! frame = [u32 BE meta_len][meta JSON (utf-8)][raw body bytes] +//! ``` +//! +//! It is intentionally scoped to loopback URLs only (validated by parsing the +//! host to an `IpAddr`), and the shared client disables redirects, so it can +//! never be used as a general outbound request primitive — a 3xx pointing +//! off-host is not followed (no SSRF / arbitrary-URL fetch). + +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri_plugin_http::reqwest::header::{HeaderName, HeaderValue}; +use tauri_plugin_http::reqwest::{redirect::Policy, Client, Method}; +use url::{Host, Url}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProxyRequestMeta { + method: String, + url: String, + headers: Vec<(String, String)>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProxyResponseMeta { + status: u16, + status_text: String, + headers: Vec<(String, String)>, +} + +/// Process-wide client: pools connections / keeps them alive across the many +/// calls a session makes, and disables redirects so the loopback guarantee +/// can't be escaped via a 3xx `Location` to a non-loopback host. +fn http_client() -> &'static Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + Client::builder() + .redirect(Policy::none()) + .build() + .expect("failed to build local-proxy reqwest client") + }) +} + +/// Only loopback hosts are permitted — this is a bundled-backend shortcut, not +/// a general HTTP proxy. Parsing to an `IpAddr` covers all of 127.0.0.0/8 and +/// `::1` in any spelling, rather than matching a few exact strings. +fn is_loopback_host(url: &Url) -> bool { + match url.host() { + Some(Host::Ipv4(ip)) => ip.is_loopback(), + Some(Host::Ipv6(ip)) => ip.is_loopback(), + Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +/// Proxy a single request to the bundled localhost backend, moving the body as +/// raw bytes instead of the plugin-http number-array. See the module docs for +/// the frame layout. +#[tauri::command] +pub async fn proxy_local_pdf_request(request: Request<'_>) -> Result { + let frame = match request.body() { + InvokeBody::Raw(bytes) => bytes, + InvokeBody::Json(_) => { + return Err("proxy_local_pdf_request: expected a raw body".into()); + } + }; + + if frame.len() < 4 { + return Err("proxy_local_pdf_request: frame too short".into()); + } + let meta_len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize; + let meta_end = 4usize + .checked_add(meta_len) + .ok_or("proxy_local_pdf_request: meta length overflow")?; + if frame.len() < meta_end { + return Err("proxy_local_pdf_request: frame truncated".into()); + } + + let meta: ProxyRequestMeta = + serde_json::from_slice(&frame[4..meta_end]).map_err(|e| e.to_string())?; + let body = frame[meta_end..].to_vec(); + + let url = Url::parse(&meta.url).map_err(|e| e.to_string())?; + if !is_loopback_host(&url) { + return Err(format!( + "proxy_local_pdf_request: refusing non-loopback url: {}", + meta.url + )); + } + let method = Method::from_bytes(meta.method.as_bytes()).map_err(|e| e.to_string())?; + + let mut builder = http_client().request(method, url); + for (name, value) in &meta.headers { + // Build the header explicitly and skip any invalid pair, rather than + // letting one malformed header fail the whole request opaquely at send(). + if let (Ok(header_name), Ok(header_value)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + builder = builder.header(header_name, header_value); + } + } + if !body.is_empty() { + builder = builder.body(body); + } + + let response = builder.send().await.map_err(|e| e.to_string())?; + let status = response.status(); + let status_text = status.canonical_reason().unwrap_or("").to_string(); + let headers: Vec<(String, String)> = response + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|v| (name.as_str().to_string(), v.to_string())) + }) + .collect(); + let body_bytes = response.bytes().await.map_err(|e| e.to_string())?; + + let meta_out = ProxyResponseMeta { + status: status.as_u16(), + status_text, + headers, + }; + let meta_json = serde_json::to_vec(&meta_out).map_err(|e| e.to_string())?; + + let mut out = Vec::with_capacity(4 + meta_json.len() + body_bytes.len()); + out.extend_from_slice(&(meta_json.len() as u32).to_be_bytes()); + out.extend_from_slice(&meta_json); + out.extend_from_slice(&body_bytes); + + Ok(Response::new(out)) +} diff --git a/frontend/editor/src-tauri/src/commands/mod.rs b/frontend/editor/src-tauri/src/commands/mod.rs index 0ff48e670b..1637c926c7 100644 --- a/frontend/editor/src-tauri/src/commands/mod.rs +++ b/frontend/editor/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod files; pub mod connection; pub mod auth; pub mod default_app; +pub mod local_proxy; pub mod platform; pub mod print; pub mod updater; @@ -40,6 +41,7 @@ pub use auth::{ start_oauth_login, }; pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler}; +pub use local_proxy::proxy_local_pdf_request; pub use platform::get_desktop_os; pub use print::print_pdf_file_native; pub use updater::{ diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index e8898b0530..8855604624 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ use commands::{ get_user_info, is_first_launch, login, + proxy_local_pdf_request, reset_setup_completion, save_auth_token, save_refresh_token, @@ -177,6 +178,7 @@ pub fn run() { is_first_launch, reset_setup_completion, login, + proxy_local_pdf_request, save_auth_token, get_auth_token, clear_auth_token, diff --git a/frontend/editor/src/desktop/services/fileOpenService.ts b/frontend/editor/src/desktop/services/fileOpenService.ts index 47df31439e..8b36cd7128 100644 --- a/frontend/editor/src/desktop/services/fileOpenService.ts +++ b/frontend/editor/src/desktop/services/fileOpenService.ts @@ -36,13 +36,20 @@ class TauriFileOpenService implements FileOpenService { const fileData = await readFile(filePath); const fileName = filePath.split(/[\\/]/).pop() || "opened-file.pdf"; - return { - fileName, - arrayBuffer: fileData.buffer.slice( - fileData.byteOffset, - fileData.byteOffset + fileData.byteLength, - ), - }; + // readFile usually returns a tightly-packed buffer; in that case hand it + // over directly instead of slicing, which would copy the entire file + // (a transient 2x memory spike for large PDFs). Only slice when the view + // is a window over a larger ArrayBuffer. + const arrayBuffer = + fileData.byteOffset === 0 && + fileData.byteLength === fileData.buffer.byteLength + ? fileData.buffer + : fileData.buffer.slice( + fileData.byteOffset, + fileData.byteOffset + fileData.byteLength, + ); + + return { fileName, arrayBuffer }; } catch (error) { console.error("Failed to read file:", error); return null; diff --git a/frontend/editor/src/desktop/services/tauriHttpClient.ts b/frontend/editor/src/desktop/services/tauriHttpClient.ts index 3a39866c44..f3d674e7e8 100644 --- a/frontend/editor/src/desktop/services/tauriHttpClient.ts +++ b/frontend/editor/src/desktop/services/tauriHttpClient.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- Axios-compatible API requires matching axios's `any` signatures */ import { fetch } from "@tauri-apps/plugin-http"; +import { + shouldUseFastLocalTransport, + fetchViaLocalProxy, + markFastTransportUnavailable, +} from "@app/services/tauriLocalProxy"; /** * Tauri HTTP Client - wrapper around Tauri's native HTTP client @@ -265,7 +270,47 @@ class TauriHttpClient { }; } - const response = await fetch(url, fetchOptions); + // Fast path: for localhost PDF uploads/downloads, move the body as raw + // bytes via the Rust proxy instead of plugin-http's number-array IPC. + // Only binary localhost traffic qualifies (see shouldUseFastLocalTransport); + // everything else — remote requests, JSON/GET calls, anything without a + // PDF body — uses the unchanged plugin-http path. Falls back to plugin-http + // automatically if the fast path throws, so behaviour is never worse. + let response: Response; + if ( + shouldUseFastLocalTransport( + url, + finalConfig.responseType, + finalConfig.data, + ) + ) { + try { + response = await fetchViaLocalProxy( + url, + method, + headers, + body, + finalConfig.signal, + ); + } catch (proxyError) { + // A deliberate abort must propagate, not silently re-issue the request. + if ( + proxyError instanceof DOMException && + proxyError.name === "AbortError" + ) { + throw proxyError; + } + // Fast path failed — trip the circuit breaker and fall back to plugin-http. + markFastTransportUnavailable(); + console.warn( + "[TauriHttpClient] local fast-path failed; reverting to plugin-http for this session", + proxyError, + ); + response = await fetch(url, fetchOptions); + } + } else { + response = await fetch(url, fetchOptions); + } // Convert Headers to plain object const responseHeaders: Record = {}; diff --git a/frontend/editor/src/desktop/services/tauriLocalProxy.ts b/frontend/editor/src/desktop/services/tauriLocalProxy.ts new file mode 100644 index 0000000000..e40719726c --- /dev/null +++ b/frontend/editor/src/desktop/services/tauriLocalProxy.ts @@ -0,0 +1,162 @@ +import { invoke } from "@tauri-apps/api/core"; + +// Fast path for localhost binary traffic: moves raw bytes via a Rust command +// instead of plugin-http's per-byte number-array IPC (~3.5x size bloat). +// See src-tauri/src/commands/local_proxy.rs. +// +// A single runtime failure trips a session-wide circuit breaker and all +// subsequent requests fall back to plugin-http transparently. +let fastTransportUnavailable = false; + +// Trip the circuit breaker so the rest of the session uses plugin-http. +export function markFastTransportUnavailable(): void { + fastTransportUnavailable = true; +} + +function isLoopbackUrl(url: string): boolean { + try { + let host = new URL(url).hostname.toLowerCase(); + // URL.hostname wraps IPv6 literals in brackets; strip them to compare. + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + return ( + host === "localhost" || + host === "::1" || + /^127(?:\.\d{1,3}){3}$/.test(host) // 127.0.0.0/8 loopback range + ); + } catch { + return false; + } +} + +/** + * Whether a request should use the raw-bytes localhost fast path. + * + * Only localhost requests that actually move a PDF qualify: a binary upload + * (FormData) or a binary download (blob/arraybuffer response). Everything else + * — remote/cloud/self-hosted requests, JSON/GET calls, anything with no PDF + * body — returns false and stays on the normal plugin-http path untouched. + */ +export function shouldUseFastLocalTransport( + url: string, + responseType: string | undefined, + data: unknown, +): boolean { + if (fastTransportUnavailable) return false; + if (!isLoopbackUrl(url)) return false; + const hasBinaryUpload = + typeof FormData !== "undefined" && data instanceof FormData; + const wantsBinaryDownload = + responseType === "blob" || responseType === "arraybuffer"; + return hasBinaryUpload || wantsBinaryDownload; +} + +interface ProxyResponseMeta { + status: number; + statusText: string; + headers: [string, string][]; +} + +// Statuses that must carry a null body (per the fetch spec). 1xx are excluded: +// the Response constructor only accepts 200–599, and they never reach here. +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +// Headers describing the ORIGINAL transfer encoding/length. They're meaningless +// (and can mismatch) once the bytes are handed to a fresh Response, which +// recomputes them — so strip them when reconstructing. +const STRIP_RESPONSE_HEADERS = new Set([ + "content-length", + "content-encoding", + "transfer-encoding", +]); + +function abortError(): DOMException { + return new DOMException("The operation was aborted.", "AbortError"); +} + +/** + * Send a request to the localhost backend via the raw-bytes Rust proxy and + * return a standard `Response`, so all downstream handling in tauriHttpClient + * (status checks, body parsing per responseType, response interceptors) is + * identical to the normal fetch path. + * + * Frame layout (both directions): [u32 BE meta_len][meta JSON][raw body]. + */ +export async function fetchViaLocalProxy( + url: string, + method: string, + headers: Record, + body: BodyInit | undefined, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw abortError(); + + // Normalise the body to raw bytes. Using Request also generates the correct + // multipart Content-Type (with boundary) for FormData, matching what the + // browser/native fetch would otherwise send. + const outHeaders: Record = { ...headers }; + let bodyBytes = new Uint8Array(0); + if (body !== undefined) { + const probe = new Request("http://localhost/", { method: "POST", body }); + bodyBytes = new Uint8Array(await probe.arrayBuffer()); + // Adopt the probe's Content-Type ONLY when the caller didn't set one. This + // captures the multipart boundary for FormData (executeRequest deletes the + // header for FormData) without clobbering an explicit type such as + // application/json that the caller already set on a JSON body. + const hasContentType = Object.keys(outHeaders).some( + (h) => h.toLowerCase() === "content-type", + ); + const contentType = probe.headers.get("content-type"); + if (contentType && !hasContentType) { + outHeaders["Content-Type"] = contentType; + } + } + + const metaBytes = new TextEncoder().encode( + JSON.stringify({ method, url, headers: Object.entries(outHeaders) }), + ); + + const frame = new Uint8Array(4 + metaBytes.length + bodyBytes.length); + new DataView(frame.buffer).setUint32(0, metaBytes.length, false); + frame.set(metaBytes, 4); + frame.set(bodyBytes, 4 + metaBytes.length); + + const invokePromise = invoke("proxy_local_pdf_request", frame); + // The Rust command can't be cancelled mid-flight, but honour the signal on + // the JS side so an abort unblocks the caller (the discarded result is GC'd). + const respBuf = signal + ? await new Promise((resolve, reject) => { + const onAbort = () => reject(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + invokePromise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }) + : await invokePromise; + + const respBytes = new Uint8Array(respBuf); + const metaLen = new DataView(respBuf).getUint32(0, false); + const respMeta: ProxyResponseMeta = JSON.parse( + new TextDecoder().decode(respBytes.subarray(4, 4 + metaLen)), + ); + const respBody = respBytes.subarray(4 + metaLen); + + const responseHeaders = respMeta.headers.filter( + ([name]) => !STRIP_RESPONSE_HEADERS.has(name.toLowerCase()), + ); + + return new Response( + NULL_BODY_STATUSES.has(respMeta.status) ? null : respBody, + { + status: respMeta.status, + statusText: respMeta.statusText, + headers: responseHeaders, + }, + ); +} diff --git a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx index 5900000879..b61296765c 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatContext.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatContext.tsx @@ -3,6 +3,7 @@ import { useContext, useReducer, useCallback, + useMemo, useRef, type ReactNode, } from "react"; @@ -403,6 +404,15 @@ export function ChatProvider({ children }: { children: ReactNode }) { const abortRef = useRef(null); const messagesRef = useRef(state.messages); messagesRef.current = state.messages; + // Hold the latest files in refs so sendMessage's identity does not change on + // every file operation. Otherwise a new sendMessage (and thus a new context + // value) would be created on each file change, re-rendering every useChat() + // consumer. sendMessage reads .current at call time, so it still sees the + // current files. + const activeFilesRef = useRef(activeFiles); + activeFilesRef.current = activeFiles; + const activeFileStubsRef = useRef(activeFileStubs); + activeFileStubsRef.current = activeFileStubs; // Download a File from the Stirling files endpoint. const downloadFile = useCallback( @@ -495,6 +505,11 @@ export function ChatProvider({ children }: { children: ReactNode }) { abortRef.current = controller; const priorMessages = messagesRef.current; + // Snapshot the files at send time so the upload AND the result-import both + // act on what the user actually sent — not on whatever the workbench holds + // when the (possibly many-seconds-later) result arrives. + const sourceFiles = activeFilesRef.current; + const sourceStubs = activeFileStubsRef.current; const startTime = Date.now(); // Mirror every progress event locally so we can attach the full log to // the assistant message when the result arrives — without needing a ref @@ -514,7 +529,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { try { const formData = new FormData(); formData.append("userMessage", content); - activeFiles.forEach((file, i) => { + sourceFiles.forEach((file, i) => { formData.append(`fileInputs[${i}].fileInput`, file); }); priorMessages.forEach((message, i) => { @@ -633,7 +648,7 @@ export function ChatProvider({ children }: { children: ReactNode }) { }, }); if (data.fileId || data.resultFiles?.length) { - importResultFile(data, activeFileStubs).catch((err) => { + importResultFile(data, sourceStubs).catch((err) => { console.error("Failed to import AI result file", err); dispatch({ type: "ADD_MESSAGE", @@ -693,24 +708,34 @@ export function ChatProvider({ children }: { children: ReactNode }) { } } }, - [activeFiles, activeFileStubs, importResultFile], + [importResultFile], ); - return ( - - {children} - + // Memoize the context value so it only changes when chat state changes — not + // on every file operation. With sendMessage/cancelMessage/clearChat all stable, + // useChat() consumers re-render only when messages/loading/progress change. + const value = useMemo( + () => ({ + messages: state.messages, + isLoading: state.isLoading, + progress: state.progress, + progressLog: state.progressLog, + sendMessage, + cancelMessage, + clearChat, + }), + [ + state.messages, + state.isLoading, + state.progress, + state.progressLog, + sendMessage, + cancelMessage, + clearChat, + ], ); + + return {children}; } export function useChat(): ChatContextValue { From d06d3cabaf5b8a7dcd0dbfc584828d8e687e5dd5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:01:32 +0100 Subject: [PATCH 8/9] chore: update svg conversion and database import handling (#6796) --- .../software/common/util/SvgSanitizer.java | 5 +- .../common/util/SvgSanitizerTest.java | 47 +++++++++ .../software/SPDF/utils/SvgOverlayUtil.java | 4 + .../software/SPDF/utils/SvgToPdf.java | 20 +++- .../software/SPDF/utils/SvgToPdfTest.java | 98 +++++++++++++++++++ .../security/service/DatabaseService.java | 29 ++++++ .../service/DatabaseServiceMoreTest.java | 57 +++++++++++ 7 files changed, 254 insertions(+), 6 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java b/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java index c5addc0f32..35305b1d55 100644 --- a/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/SvgSanitizer.java @@ -244,10 +244,7 @@ public class SvgSanitizer { return false; } - return normalized.startsWith("http://") - || normalized.startsWith("https://") - || normalized.startsWith("//") - || normalized.startsWith("file:"); + return true; } private boolean isUrlAllowed(String url) { diff --git a/app/common/src/test/java/stirling/software/common/util/SvgSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/SvgSanitizerTest.java index 433cf548d3..7b1c519a06 100644 --- a/app/common/src/test/java/stirling/software/common/util/SvgSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/SvgSanitizerTest.java @@ -97,4 +97,51 @@ class SvgSanitizerTest { byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8); assertThrows(IOException.class, () -> sanitizer.sanitize(invalid)); } + + @Test + void testSanitize_removesRootRelativeLocalPath() throws IOException { + when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); + String svg = + "" + + ""; + byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8)); + String output = new String(result, StandardCharsets.UTF_8); + assertFalse(output.contains("/tmp/image.png"), "Root-relative local path must be stripped"); + } + + @Test + void testSanitize_removesRelativeLocalPath() throws IOException { + when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); + String svg = + "" + + ""; + byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8)); + String output = new String(result, StandardCharsets.UTF_8); + assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped"); + } + + @Test + void testSanitize_removesRootRelativeWindowsDrivePath() throws IOException { + when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); + String svg = + "" + + ""; + byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8)); + String output = new String(result, StandardCharsets.UTF_8); + assertFalse( + output.contains("external-image"), "Root-relative Windows path must be stripped"); + } + + @Test + void testSanitize_keepsInDocumentFragmentReference() throws IOException { + String svg = + "" + + ""; + byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8)); + String output = new String(result, StandardCharsets.UTF_8); + assertTrue( + output.contains("#gradient"), "In-document fragment references must be preserved"); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java b/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java index b58bb055fc..2c7f708cc0 100644 --- a/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java +++ b/app/core/src/main/java/stirling/software/SPDF/utils/SvgOverlayUtil.java @@ -45,6 +45,10 @@ public class SvgOverlayUtil { @Override public void checkLoadExternalResource( ParsedURL resourceURL, ParsedURL docURL) { + // Inline data: URIs are self-contained (no network/file fetch). + if (resourceURL != null && "data".equals(resourceURL.getProtocol())) { + return; + } throw new SecurityException( "External resource loading is disabled for SVG overlays: " + resourceURL); diff --git a/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java b/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java index 54be5ca0ea..3d94df121f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java +++ b/app/core/src/main/java/stirling/software/SPDF/utils/SvgToPdf.java @@ -19,6 +19,7 @@ import org.apache.batik.bridge.GVTBuilder; import org.apache.batik.bridge.UserAgent; import org.apache.batik.bridge.UserAgentAdapter; import org.apache.batik.gvt.GraphicsNode; +import org.apache.batik.util.ParsedURL; import org.apache.batik.util.XMLResourceDescriptor; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; @@ -63,7 +64,7 @@ public class SvgToPdf { } // 2. Build the GVT (Graphics Vector Tree) with timeout protection - UserAgent userAgent = new UserAgentAdapter(); + UserAgent userAgent = createSecureUserAgent(); DocumentLoader loader = new DocumentLoader(userAgent); BridgeContext ctx = new BridgeContext(userAgent, loader); ctx.setDynamicState(BridgeContext.DYNAMIC); @@ -94,6 +95,21 @@ public class SvgToPdf { } } + private UserAgent createSecureUserAgent() { + return new UserAgentAdapter() { + @Override + public void checkLoadExternalResource(ParsedURL resourceURL, ParsedURL docURL) { + // Inline data: URIs are self-contained (no network/file fetch) - allow them. + if (resourceURL != null && "data".equals(resourceURL.getProtocol())) { + return; + } + throw new SecurityException( + "External resource loading is disabled for SVG to PDF conversion: " + + resourceURL); + } + }; + } + private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc) throws IOException { GVTBuilder builder = new GVTBuilder(); @@ -202,7 +218,7 @@ public class SvgToPdf { svgDoc = factory.createSVGDocument("file:///input.svg", inputStream); } - UserAgent userAgent = new UserAgentAdapter(); + UserAgent userAgent = createSecureUserAgent(); DocumentLoader loader = new DocumentLoader(userAgent); BridgeContext ctx = new BridgeContext(userAgent, loader); ctx.setDynamicState(BridgeContext.DYNAMIC); diff --git a/app/core/src/test/java/stirling/software/SPDF/utils/SvgToPdfTest.java b/app/core/src/test/java/stirling/software/SPDF/utils/SvgToPdfTest.java index 7266efe220..63c3c73ffc 100644 --- a/app/core/src/test/java/stirling/software/SPDF/utils/SvgToPdfTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/utils/SvgToPdfTest.java @@ -2,12 +2,23 @@ package stirling.software.SPDF.utils; import static org.junit.jupiter.api.Assertions.*; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.List; +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; import org.junit.jupiter.api.Test; class SvgToPdfTest { @@ -124,4 +135,91 @@ class SvgToPdfTest { List svgs = Arrays.asList(null, null, new byte[0]); assertThrows(IOException.class, () -> SvgToPdf.combineIntoPdf(svgs)); } + + @Test + void convert_doesNotEmbedExternalFileResource() throws Exception { + Path external = Files.createTempFile("svg-external", ".png"); + BufferedImage red = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = red.createGraphics(); + g.setColor(Color.RED); + g.fillRect(0, 0, 100, 100); + g.dispose(); + ImageIO.write(red, "png", external.toFile()); + + try { + String svg = + "" + + ""; + + byte[] pdf; + try { + pdf = SvgToPdf.convert(svg.getBytes(StandardCharsets.UTF_8)); + } catch (IOException blocked) { + return; + } + + try (PDDocument doc = Loader.loadPDF(pdf)) { + BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72); + int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2); + int r = (rgb >> 16) & 0xff; + int gg = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + assertFalse( + r > 200 && gg < 60 && b < 60, + "External file image must not be rendered into the PDF"); + } + } finally { + Files.deleteIfExists(external); + } + } + + // A self-contained SVG whose only content is an inline base64 data: image (a solid-red vector + // SVG). Vector data: images decode via batik-bridge without batik-codec, so this exercises the + // data: security allowance independent of raster codecs. + private static String svgWithInlineRedImage() { + String innerSvg = + "" + + ""; + String dataUri = + "data:image/svg+xml;base64," + + Base64.getEncoder() + .encodeToString(innerSvg.getBytes(StandardCharsets.UTF_8)); + return "" + + ""; + } + + @Test + void convert_rendersInlineDataUriImage() throws IOException { + byte[] pdf = SvgToPdf.convert(svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8)); + try (PDDocument doc = Loader.loadPDF(pdf)) { + BufferedImage page = new PDFRenderer(doc).renderImageWithDPI(0, 72); + int rgb = page.getRGB(page.getWidth() / 2, page.getHeight() / 2); + int r = (rgb >> 16) & 0xff; + int gg = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + assertTrue( + r > 200 && gg < 60 && b < 60, + "Inline data: image must be rendered into the PDF (center rgb=" + + Integer.toHexString(rgb) + + ")"); + } + } + + @Test + void combineIntoPdf_keepsPageWithInlineDataUriImage() throws IOException { + List svgs = + List.of( + SIMPLE_SVG.getBytes(StandardCharsets.UTF_8), + svgWithInlineRedImage().getBytes(StandardCharsets.UTF_8)); + byte[] pdf = SvgToPdf.combineIntoPdf(svgs); + try (PDDocument doc = Loader.loadPDF(pdf)) { + assertEquals(2, doc.getNumberOfPages(), "inline data: image page must not be dropped"); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java index 2246903d2d..ffd9b5cb17 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/DatabaseService.java @@ -90,6 +90,20 @@ public class DatabaseService implements DatabaseServiceInterface { Pattern.compile("(?i)\\bFALSE\\b"), Pattern.compile("(?i)\\bNULL\\b")); + private static final java.util.List DENIED_PATTERNS = + java.util.List.of( + Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?ALIAS\\b"), + Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?TRIGGER\\b"), + Pattern.compile("(?i)\\bCREATE\\s+(FORCE\\s+)?AGGREGATE\\b"), + Pattern.compile("(?i)\\bCREATE\\s+LINKED\\s+TABLE\\b"), + Pattern.compile("(?i)\\bFILE_WRITE\\s*\\("), + Pattern.compile("(?i)\\bFILE_READ\\s*\\("), + Pattern.compile("(?i)\\bCSVWRITE\\s*\\("), + Pattern.compile("(?i)\\bCSVREAD\\s*\\("), + Pattern.compile("(?i)\\bLINK_SCHEMA\\s*\\("), + Pattern.compile("(?i)\\bRUNSCRIPT\\b"), + Pattern.compile("(?i)\\bSCRIPT\\s+TO\\b")); + private final ApplicationProperties.Datasource datasourceProps; private final DataSource dataSource; private final DatabaseNotificationServiceInterface backupNotificationService; @@ -508,6 +522,17 @@ public class DatabaseService implements DatabaseServiceInterface { String content = Files.readString(scriptPath); String normalizedContent = normalizeSqlContent(content); + String codeOnly = stripStringLiterals(normalizedContent); + for (Pattern deniedPattern : DENIED_PATTERNS) { + if (deniedPattern.matcher(codeOnly).find()) { + log.error( + "Blocked disallowed SQL in backup file matching: {}", + deniedPattern.pattern()); + throw new IllegalArgumentException( + "SQL script contains disallowed operations and was rejected."); + } + } + // Validate that content only contains allowed operations (whitelist approach) // Split by semicolons to check individual statements String[] statements = normalizedContent.split(";"); @@ -558,6 +583,10 @@ public class DatabaseService implements DatabaseServiceInterface { return sql.trim(); } + private String stripStringLiterals(String sql) { + return sql.replaceAll("'(?:[^']|'')*'", "''"); + } + /** * Checks for invalid characters or sequences * diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java index 6bf8422ef7..27643054ab 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java @@ -168,4 +168,61 @@ class DatabaseServiceMoreTest { assertThat(resolved.startsWith(tempDir)).isTrue(); } } + + @Nested + @DisplayName("SQL import validation") + class SqlValidation { + + private Path writeScript(String sql) throws IOException { + Path script = Files.createTempFile("import", ".sql"); + Files.writeString(script, sql); + return script; + } + + @Test + @DisplayName("rejects FILE_WRITE") + void rejectsFileWrite() throws IOException { + Path script = writeScript("CREATE TABLE X AS SELECT FILE_WRITE('data', '/tmp/out');"); + assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("rejects FILE_READ in an INSERT") + void rejectsFileRead() throws IOException { + Path script = writeScript("INSERT INTO PUBLIC.X(D) VALUES (FILE_READ('/tmp/in'));"); + assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("rejects CREATE ALIAS") + void rejectsCreateAlias() throws IOException { + Path script = + writeScript( + "CREATE ALIAS MYALIAS AS 'String run(String c) throws Exception" + + " { return c; }';"); + assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("rejects RUNSCRIPT FROM") + void rejectsRunscript() throws IOException { + Path script = writeScript("RUNSCRIPT FROM '/tmp/other.sql';"); + assertThatThrownBy(() -> databaseService.importDatabaseFromUI(script)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("allows a backup whose data mentions a keyword") + void allowsKeywordInsideStringData() throws IOException { + Path script = + writeScript( + "CREATE TABLE PUBLIC.NOTES(ID INT, BODY CHARACTER VARYING);" + + " INSERT INTO PUBLIC.NOTES(ID, BODY)" + + " VALUES(1, 'plain text with FILE_WRITE() inside');"); + assertThat(databaseService.importDatabaseFromUI(script)).isTrue(); + } + } } From bc6f1a1ff5ff92f7fb6020ff6c421f9f331a41d6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:07:19 +0100 Subject: [PATCH 9/9] Add login agreement disclaimer feature (#6766) --- .../common/model/ApplicationProperties.java | 8 + .../common/service/LoginAgreementService.java | 204 +++++++++++++ .../service/LoginAgreementServiceTest.java | 201 +++++++++++++ .../api/misc/LoginDisclaimerController.java | 49 +++ .../src/main/resources/settings.yml.template | 4 + .../misc/LoginDisclaimerControllerTest.java | 63 ++++ .../api/AdminLoginAgreementController.java | 74 +++++ .../AdminLoginAgreementControllerTest.java | 76 +++++ .../public/locales/de-DE/translation.toml | 4 + .../public/locales/en-GB/translation.toml | 4 + .../public/locales/en-US/translation.toml | 28 ++ .../public/locales/es-ES/translation.toml | 4 + .../public/locales/fr-FR/translation.toml | 4 + .../editor/src-tauri/provisioner/src/main.rs | 26 +- .../editor/src-tauri/src/commands/backend.rs | 12 +- .../src-tauri/src/commands/connection.rs | 38 ++- .../src-tauri/windows/wix/provisioning.wxs | 19 +- .../editor/src/core/components/AppLayout.tsx | 2 + .../components/shared/LoginAgreementModal.tsx | 202 +++++++++++++ .../src/core/tests/helpers/api-stubs.ts | 6 + .../stubbed/login-agreement-modal.spec.ts | 139 +++++++++ .../configSections/AdminLegalSection.tsx | 91 ++++++ .../configSections/LoginAgreementEditor.tsx | 281 ++++++++++++++++++ 23 files changed, 1523 insertions(+), 16 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java create mode 100644 app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java create mode 100644 frontend/editor/src/core/components/shared/LoginAgreementModal.tsx create mode 100644 frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx 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 e80d6b2360..8f7e878aef 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 @@ -514,6 +514,14 @@ public class ApplicationProperties { private String accessibilityStatement; private String cookiePolicy; private String impressum; + private LoginAgreement loginAgreement = new LoginAgreement(); + + @Data + public static class LoginAgreement { + private boolean enabled = false; + private boolean showInAnonymousMode = true; + private String fallbackText = ""; + } } @Data diff --git a/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java b/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java new file mode 100644 index 0000000000..ddde3fa1a8 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/LoginAgreementService.java @@ -0,0 +1,204 @@ +package stirling.software.common.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; + +// Resolves login agreement text from customFiles/disclaimer/.md (read live); +// enable/visibility come from the legal.loginAgreement settings. +@Service +@Slf4j +public class LoginAgreementService { + + // Locale codes only: rejects path separators and dots so the value can never escape the + // disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR. + private static final Pattern LOCALE_PATTERN = + Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$"); + + // BCP-47 tags are well under this; the cap also prevents the regex's repetition group + // from recursing far enough to overflow the stack on a hostile over-length input. + private static final int MAX_LOCALE_LENGTH = 35; + + // Disclaimers are short markdown; cap the read so an oversized file can't be loaded + // wholesale into heap on every public request. + private static final long MAX_FILE_BYTES = 256 * 1024; + + private final ApplicationProperties applicationProperties; + + public LoginAgreementService(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + public boolean isEnabled() { + return config().isEnabled(); + } + + public boolean isShowInAnonymousMode() { + return config().isShowInAnonymousMode(); + } + + /** + * Resolve the markdown to show for the requested language, falling back through the base + * language, the configured default locale (and its base), then the configured fallbackText. + * Returns an empty string when nothing is configured. + */ + public String resolveContent(String requestedLang) { + List candidates = new ArrayList<>(); + addLocaleCandidates(candidates, requestedLang); + addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale()); + + for (String candidate : candidates) { + String content = readFileIfExists(candidate); + if (content != null && !content.isBlank()) { + return content; + } + } + + String fallback = config().getFallbackText(); + return fallback == null ? "" : fallback; + } + + /** + * Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent. + */ + public String readRawForLocale(String locale) { + if (!isValidLocale(locale)) { + return null; + } + String content = readFileIfExists(locale); + return content == null ? "" : content; + } + + /** Admin write. Blank content deletes the file so it falls back cleanly. */ + public void writeForLocale(String locale, String content) throws IOException { + Path file = resolveLocaleFile(locale); + if (file == null) { + throw new IllegalArgumentException("Invalid locale: " + locale); + } + if (content == null || content.isBlank()) { + Files.deleteIfExists(file); + return; + } + Files.createDirectories(file.getParent()); + // Write to a sibling temp file then atomically swap, so a concurrent reader (the public + // /login-disclaimer fetch is lockless) never observes a truncated/partial file. + Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp"); + try { + Files.writeString(tmp, content, StandardCharsets.UTF_8); + try { + Files.move( + tmp, + file, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(tmp); + } + } + + /** Locales that currently have a markdown file, for the admin editor. */ + public Set listLocalesWithContent() { + Set result = new TreeSet<>(); + Path dir = disclaimerDir(); + if (!Files.isDirectory(dir)) { + return result; + } + try (Stream files = Files.list(dir)) { + files.filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.endsWith(".md")) + .map(name -> name.substring(0, name.length() - ".md".length())) + .filter(this::isValidLocale) + .forEach(result::add); + } catch (IOException e) { + log.warn("Failed listing login agreement files", e); + } + return result; + } + + private ApplicationProperties.Legal.LoginAgreement config() { + return applicationProperties.getLegal().getLoginAgreement(); + } + + private Path disclaimerDir() { + return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize(); + } + + private void addLocaleCandidates(List out, String locale) { + if (!isValidLocale(locale)) { + return; + } + if (!out.contains(locale)) { + out.add(locale); + } + String base = locale.split("[_-]", 2)[0]; + if (!base.equals(locale) && !out.contains(base)) { + out.add(base); + } + } + + private String readFileIfExists(String locale) { + Path file = resolveLocaleFile(locale); + if (file == null) { + return null; + } + try { + // NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a + // planted symlink can't expose files outside the disclaimer dir via the public read. + if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + if (Files.size(file) > MAX_FILE_BYTES) { + log.warn( + "Login agreement file for locale {} exceeds {} bytes; ignoring", + locale, + MAX_FILE_BYTES); + return null; + } + return Files.readString(file, StandardCharsets.UTF_8); + } + } catch (IOException e) { + log.warn("Failed reading login agreement file for locale {}", locale, e); + } + return null; + } + + private Path resolveLocaleFile(String locale) { + if (!isValidLocale(locale)) { + return null; + } + Path dir = disclaimerDir(); + Path file = dir.resolve(locale + ".md").normalize(); + // Defence in depth: the regex already blocks separators, but confirm containment. + if (!file.startsWith(dir)) { + return null; + } + return file; + } + + private boolean isValidLocale(String locale) { + // Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack + // frame per repeat in java.util.regex, so an unbounded input could overflow the stack. + return locale != null + && locale.length() <= MAX_LOCALE_LENGTH + && LOCALE_PATTERN.matcher(locale).matches(); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java b/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java new file mode 100644 index 0000000000..237242b1f3 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/LoginAgreementServiceTest.java @@ -0,0 +1,201 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; + +/** + * Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from + * {@code /disclaimer/.md}; here {@link + * InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is + * isolated. + */ +class LoginAgreementServiceTest { + + @TempDir Path customFilesDir; + + private ApplicationProperties properties; + private ApplicationProperties.Legal.LoginAgreement config; + private LoginAgreementService service; + private Path disclaimerDir; + + @BeforeEach + void setUp() { + properties = new ApplicationProperties(); + config = properties.getLegal().getLoginAgreement(); + service = new LoginAgreementService(properties); + disclaimerDir = customFilesDir.resolve("disclaimer"); + } + + /** + * Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir. + */ + private void withMockedPath(Runnable action) { + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getCustomFilesPath) + .thenReturn(customFilesDir.toString()); + action.run(); + } + } + + private void writeFile(String locale, String content) throws IOException { + Files.createDirectories(disclaimerDir); + Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8); + } + + @Test + void flagsReflectConfig() { + config.setEnabled(true); + config.setShowInAnonymousMode(false); + assertTrue(service.isEnabled()); + assertFalse(service.isShowInAnonymousMode()); + } + + @Test + void resolveContentReturnsExactLocaleFile() throws IOException { + writeFile("fr-FR", "# Avis"); + withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR"))); + } + + @Test + void resolveContentFallsBackToBaseLanguage() throws IOException { + // Only a language-only file exists; a region-specific request should fall back to it. + writeFile("de", "# Hinweis"); + withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE"))); + } + + @Test + void resolveContentFallsBackToDefaultLocale() throws IOException { + properties.getSystem().setDefaultLocale("en-GB"); + writeFile("en-GB", "# Notice"); + // No file for the requested locale -> falls through to the configured default locale. + withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES"))); + } + + @Test + void resolveContentFallsBackToFallbackTextWhenNoFile() { + config.setFallbackText("# Fallback"); + withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP"))); + } + + @Test + void resolveContentReturnsEmptyWhenNothingConfigured() { + withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP"))); + } + + @Test + void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException { + // Plant a file outside the disclaimer dir; a traversal-style locale must not read it. + Files.writeString( + customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8); + config.setFallbackText("safe"); + withMockedPath( + () -> { + assertEquals("safe", service.resolveContent("../secret")); + assertEquals("safe", service.resolveContent("..%2Fsecret")); + assertEquals("safe", service.resolveContent("/etc/passwd")); + }); + } + + @Test + void readRawRejectsInvalidLocale() { + withMockedPath( + () -> { + assertNull(service.readRawForLocale("../secret")); + assertNull(service.readRawForLocale("en/GB")); + assertNull(service.readRawForLocale("C:\\x")); + assertNull(service.readRawForLocale(null)); + }); + } + + @Test + void readRawReturnsEmptyForValidButAbsentLocale() { + withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR"))); + } + + @Test + void overlongLocaleIsRejectedWithoutStackOverflow() { + // Guards against the regex-recursion stack overflow on unbounded input. + String hostile = "en" + "-ab".repeat(4000); + withMockedPath( + () -> { + assertDoesNotThrow(() -> service.readRawForLocale(hostile)); + assertNull(service.readRawForLocale(hostile)); + assertDoesNotThrow(() -> service.resolveContent(hostile)); + }); + } + + @Test + void writeThenReadRoundTrips() throws IOException { + withMockedPath( + () -> { + assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour")); + assertEquals("# Bonjour", service.readRawForLocale("fr-FR")); + }); + assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md"))); + } + + @Test + void writeBlankDeletesFile() throws IOException { + writeFile("fr-FR", "# Bonjour"); + withMockedPath( + () -> { + assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " ")); + assertEquals("", service.readRawForLocale("fr-FR")); + }); + assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md"))); + } + + @Test + void writeRejectsInvalidLocale() { + withMockedPath( + () -> + assertThrows( + IllegalArgumentException.class, + () -> service.writeForLocale("../escape", "x"))); + } + + @Test + void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException { + writeFile("en-GB", "a"); + writeFile("fr-FR", "b"); + Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8); + withMockedPath( + () -> { + var locales = service.listLocalesWithContent(); + assertTrue(locales.contains("en-GB")); + assertTrue(locales.contains("fr-FR")); + assertEquals(2, locales.size()); + }); + } + + @Test + void oversizedFileIsIgnored() throws IOException { + // Files beyond the read cap are skipped rather than loaded into heap. + byte[] big = new byte[300 * 1024]; + java.util.Arrays.fill(big, (byte) 'x'); + Files.createDirectories(disclaimerDir); + Files.write(disclaimerDir.resolve("en-GB.md"), big); + config.setFallbackText("small-fallback"); + properties.getSystem().setDefaultLocale("en-GB"); + withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB"))); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java new file mode 100644 index 0000000000..241caff12d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerController.java @@ -0,0 +1,49 @@ +package stirling.software.SPDF.controller.api.misc; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.annotations.api.ConfigApi; +import stirling.software.common.service.LoginAgreementService; + +/** + * Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules: + * it requires authentication when login is enabled (the modal is shown after login, never on the + * login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live + * from disk, so admin edits take effect on the next login without a restart. + */ +@ConfigApi +@Hidden +@RequiredArgsConstructor +public class LoginDisclaimerController { + + private final LoginAgreementService loginAgreementService; + + @GetMapping("/login-disclaimer") + @Operation( + summary = "Get the login agreement/disclaimer", + description = + "Returns whether the login agreement is enabled and, if so, the markdown to" + + " display for the requested language.") + public LoginDisclaimerResponse getLoginDisclaimer( + @RequestParam(name = "lang", required = false) String lang) { + boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode(); + if (!loginAgreementService.isEnabled()) { + return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown"); + } + String content = loginAgreementService.resolveContent(lang); + // Enabled but no resolvable text (no file for any candidate locale and no fallbackText): + // report disabled so clients don't try to render an empty agreement. + boolean hasContent = content != null && !content.isBlank(); + return new LoginDisclaimerResponse( + hasContent, showInAnonymousMode, hasContent ? content : "", "markdown"); + } + + public record LoginDisclaimerResponse( + boolean enabled, boolean showInAnonymousMode, String content, String format) {} +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1e9e103742..fe2b8635aa 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -165,6 +165,10 @@ legal: accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder + loginAgreement: + enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/.md (e.g. en-GB.md, fr-FR.md) + showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode + fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs) system: defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java new file mode 100644 index 0000000000..31b64cb2c7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/LoginDisclaimerControllerTest.java @@ -0,0 +1,63 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.SPDF.controller.api.misc.LoginDisclaimerController.LoginDisclaimerResponse; +import stirling.software.common.service.LoginAgreementService; + +@ExtendWith(MockitoExtension.class) +class LoginDisclaimerControllerTest { + + @Mock LoginAgreementService loginAgreementService; + + @InjectMocks LoginDisclaimerController controller; + + @Test + void disabledReturnsEmptyContent() { + when(loginAgreementService.isEnabled()).thenReturn(false); + when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true); + + LoginDisclaimerResponse resp = controller.getLoginDisclaimer("en-GB"); + + assertFalse(resp.enabled()); + assertEquals("", resp.content()); + assertTrue(resp.showInAnonymousMode()); + assertEquals("markdown", resp.format()); + } + + @Test + void enabledWithContentReturnsIt() { + when(loginAgreementService.isEnabled()).thenReturn(true); + when(loginAgreementService.isShowInAnonymousMode()).thenReturn(false); + when(loginAgreementService.resolveContent("fr-FR")).thenReturn("# Avis"); + + LoginDisclaimerResponse resp = controller.getLoginDisclaimer("fr-FR"); + + assertTrue(resp.enabled()); + assertEquals("# Avis", resp.content()); + assertFalse(resp.showInAnonymousMode()); + } + + @Test + void enabledButBlankContentReportsDisabled() { + // No file for any candidate locale and no fallbackText -> report disabled so clients + // don't render an empty agreement. + when(loginAgreementService.isEnabled()).thenReturn(true); + when(loginAgreementService.isShowInAnonymousMode()).thenReturn(true); + when(loginAgreementService.resolveContent("ja-JP")).thenReturn(" "); + + LoginDisclaimerResponse resp = controller.getLoginDisclaimer("ja-JP"); + + assertFalse(resp.enabled()); + assertEquals("", resp.content()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java new file mode 100644 index 0000000000..320ea7fcd0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementController.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.security.controller.api; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.LoginAgreementService; + +/** + * Admin editing of the per-language login agreement markdown files + * (customFiles/disclaimer/<locale>.md). The enable/visibility flags are managed through the + * normal admin settings endpoints; only the live-edited text is handled here. + */ +@RestController +@RequestMapping("/api/v1/admin/login-agreement") +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@Tag(name = "Admin Settings", description = "Login agreement text management") +@Hidden +@Slf4j +public class AdminLoginAgreementController { + + private final LoginAgreementService loginAgreementService; + + @GetMapping + @Operation(summary = "List locales that currently have login agreement text") + public Set listLocales() { + return loginAgreementService.listLocalesWithContent(); + } + + @GetMapping("/{locale}") + @Operation(summary = "Read the login agreement markdown for a locale") + public ResponseEntity> read(@PathVariable String locale) { + String content = loginAgreementService.readRawForLocale(locale); + if (content == null) { + return ResponseEntity.badRequest().build(); + } + return ResponseEntity.ok(Map.of("locale", locale, "content", content)); + } + + @PutMapping("/{locale}") + @Operation(summary = "Write the login agreement markdown for a locale (blank clears it)") + public ResponseEntity write( + @PathVariable String locale, @RequestBody DisclaimerContentRequest request) { + try { + loginAgreementService.writeForLocale( + locale, request == null ? null : request.content()); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (IOException e) { + log.error("Failed writing login agreement for locale {}", locale, e); + return ResponseEntity.internalServerError().build(); + } + } + + public record DisclaimerContentRequest(String content) {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java new file mode 100644 index 0000000000..13898b1562 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLoginAgreementControllerTest.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.service.LoginAgreementService; +import stirling.software.proprietary.security.controller.api.AdminLoginAgreementController.DisclaimerContentRequest; + +@ExtendWith(MockitoExtension.class) +class AdminLoginAgreementControllerTest { + + @Mock LoginAgreementService loginAgreementService; + + @InjectMocks AdminLoginAgreementController controller; + + @Test + void listDelegatesToService() { + when(loginAgreementService.listLocalesWithContent()).thenReturn(Set.of("en-GB", "fr-FR")); + assertEquals(Set.of("en-GB", "fr-FR"), controller.listLocales()); + } + + @Test + void readReturnsContentForValidLocale() { + when(loginAgreementService.readRawForLocale("fr-FR")).thenReturn("# Avis"); + ResponseEntity resp = controller.read("fr-FR"); + assertEquals(HttpStatus.OK, resp.getStatusCode()); + } + + @Test + void readReturnsBadRequestForInvalidLocale() { + // Service returns null for an invalid locale. + when(loginAgreementService.readRawForLocale("../escape")).thenReturn(null); + ResponseEntity resp = controller.read("../escape"); + assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode()); + } + + @Test + void writeDelegatesAndReturnsNoContent() throws IOException { + ResponseEntity resp = controller.write("fr-FR", new DisclaimerContentRequest("# Hi")); + assertEquals(HttpStatus.NO_CONTENT, resp.getStatusCode()); + verify(loginAgreementService).writeForLocale("fr-FR", "# Hi"); + } + + @Test + void writeReturnsBadRequestOnInvalidLocale() throws IOException { + doThrow(new IllegalArgumentException("Invalid locale")) + .when(loginAgreementService) + .writeForLocale(eq("../escape"), eq("x")); + ResponseEntity resp = + controller.write("../escape", new DisclaimerContentRequest("x")); + assertEquals(HttpStatus.BAD_REQUEST, resp.getStatusCode()); + } + + @Test + void writeReturnsServerErrorOnIoException() throws IOException { + doThrow(new IOException("disk full")) + .when(loginAgreementService) + .writeForLocale(eq("fr-FR"), eq("x")); + ResponseEntity resp = controller.write("fr-FR", new DisclaimerContentRequest("x")); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, resp.getStatusCode()); + } +} diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 55a5022189..97c9192824 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -55,6 +55,10 @@ info = "Informationen" invalidUndoData = "Rückgängig nicht möglich: ungültige Vorgangsdaten" keepWorking = "Weiterarbeiten" loading = "Laden..." +loginAgreementAccept = "Akzeptieren" +loginAgreementDecline = "Ablehnen" +loginAgreementProvider = "Dieser Hinweis stammt von Ihrem Administrator, nicht von Stirling PDF Inc." +loginAgreementTitle = "Anmeldevereinbarung" logOut = "Abmelden" marginTooltip = "Abstand zwischen der Seitenzahl und dem Seitenrand." moreOptions = "Weitere Optionen" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 7a97f4e4a9..f3c7a77ae5 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -55,6 +55,10 @@ info = "Info" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." +loginAgreementAccept = "Accept" +loginAgreementDecline = "Decline" +loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc." +loginAgreementTitle = "Login Agreement" logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 84f9c26de5..14768a3208 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -55,6 +55,10 @@ info = "Info" invalidUndoData = "Cannot undo: invalid operation data" keepWorking = "Keep Working" loading = "Loading..." +loginAgreementAccept = "Accept" +loginAgreementDecline = "Decline" +loginAgreementProvider = "This notice is provided by your administrator, not Stirling PDF Inc." +loginAgreementTitle = "Login Agreement" logOut = "Log out" marginTooltip = "Distance between the page number and the edge of the page." moreOptions = "More Options" @@ -1072,6 +1076,30 @@ title = "Legal Responsibility Warning" description = "URL or filename to impressum (required in some jurisdictions)" label = "Impressum" +[admin.settings.legal.loginAgreement] +defaultLocaleHint = "— used as the fallback when a language has no file." +defaultLocaleLink = "Configure the default locale" +description = "Show a disclaimer users must accept after logging in. The text follows each user's language." +discardConfirm = "You have unsaved changes that will be lost. Switch language anyway?" +editLabel = "Markdown" +emptyPreview = "Nothing to preview yet." +language = "Language" +languageHelp = "Each language has its own file. If a user's language has no file, the agreement falls back to the default locale's file, then to the fallback text." +loadError = "Failed to load the agreement for {{locale}}. Switch language and back to retry." +previewLabel = "Preview" +restartNote = "Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately." +save = "Save text" +saved = "Saved" +savedBody = "Login agreement updated for {{locale}}" +textDescription = "Saved to customFiles/disclaimer/{{locale}}.md and shown live on the next login. Leave blank to remove this language." +title = "Login Agreement" + +[admin.settings.legal.loginAgreement.anonymous] +label = "Show in anonymous (no-login) mode" + +[admin.settings.legal.loginAgreement.enabled] +label = "Enable login agreement" + [admin.settings.legal.privacyPolicy] description = "URL or filename to privacy policy" label = "Privacy Policy" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index ecac7c9ef1..8856536f82 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -55,6 +55,10 @@ info = "Información" invalidUndoData = "No se puede deshacer: datos de la operación no válidos" keepWorking = "Seguir trabajando" loading = "Cargando..." +loginAgreementAccept = "Aceptar" +loginAgreementDecline = "Rechazar" +loginAgreementProvider = "Este aviso lo proporciona su administrador, no Stirling PDF Inc." +loginAgreementTitle = "Acuerdo de acceso" logOut = "Cerrar sesión" marginTooltip = "Distancia entre el número de página y el borde de la página." moreOptions = "Más Opciones" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index d4def7ab17..49baa951a4 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -55,6 +55,10 @@ info = "Informations" invalidUndoData = "Impossible d’annuler : données d’opération invalides" keepWorking = "Continuer à travailler" loading = "Chargement..." +loginAgreementAccept = "Accepter" +loginAgreementDecline = "Refuser" +loginAgreementProvider = "Cet avis est fourni par votre administrateur, et non par Stirling PDF Inc." +loginAgreementTitle = "Accord de connexion" logOut = "Se déconnecter" marginTooltip = "Distance entre le numéro de page et le bord de la page." moreOptions = "Plus d’options" diff --git a/frontend/editor/src-tauri/provisioner/src/main.rs b/frontend/editor/src-tauri/provisioner/src/main.rs index 1a316945c5..b946c31289 100644 --- a/frontend/editor/src-tauri/provisioner/src/main.rs +++ b/frontend/editor/src-tauri/provisioner/src/main.rs @@ -10,6 +10,8 @@ struct ProvisioningConfig<'a> { server_url: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] lock_connection_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + login_agreement_enabled: Option, /// Optional headless-install update policy. /// One of `"prompt"` (default), `"auto"`, or `"disabled"`. #[serde(skip_serializing_if = "Option::is_none")] @@ -44,6 +46,7 @@ fn main() -> Result<(), String> { let mut output: Option = None; let mut url: Option = None; let mut lock_value: Option = None; + let mut login_agreement_value: Option = None; let mut update_mode_arg: Option = None; let mut args = env::args().skip(1); @@ -67,6 +70,12 @@ fn main() -> Result<(), String> { .ok_or_else(|| "--lock requires a value".to_string())?; lock_value = Some(value); } + "--login-agreement" => { + let value = args + .next() + .ok_or_else(|| "--login-agreement requires a value".to_string())?; + login_agreement_value = Some(value); + } "--update-mode" => { let value = args .next() @@ -84,6 +93,15 @@ fn main() -> Result<(), String> { let url = url .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); + // Treat an empty/whitespace value as "not supplied" (None), matching url and + // update-mode. The MSI always passes --login-agreement "[STIRLING_LOGIN_AGREEMENT]", + // which expands to "" when the property is unset; that must NOT write + // loginAgreementEnabled:false and clobber a previously-provisioned true. + let login_agreement = login_agreement_value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(parse_bool); let update_mode = update_mode_arg .as_deref() @@ -91,9 +109,10 @@ fn main() -> Result<(), String> { .transpose()? .flatten(); - // Nothing to write — avoid clobbering an existing provisioning file when - // the MSI is invoked without any of STIRLING_SERVER_URL / STIRLING_UPDATE_MODE. - if url.is_none() && update_mode.is_none() { + // Nothing to write — avoid clobbering an existing provisioning file when the + // MSI is invoked without any provisioning directives + // (STIRLING_SERVER_URL / STIRLING_LOGIN_AGREEMENT / STIRLING_UPDATE_MODE). + if url.is_none() && login_agreement.is_none() && update_mode.is_none() { return Ok(()); } @@ -111,6 +130,7 @@ fn main() -> Result<(), String> { let config = ProvisioningConfig { server_url: url.as_deref(), lock_connection_mode: lock, + login_agreement_enabled: login_agreement, update_mode, }; diff --git a/frontend/editor/src-tauri/src/commands/backend.rs b/frontend/editor/src-tauri/src/commands/backend.rs index 00dc1bb486..50d9a24a26 100644 --- a/frontend/editor/src-tauri/src/commands/backend.rs +++ b/frontend/editor/src-tauri/src/commands/backend.rs @@ -203,7 +203,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & // Define all Java options with Tauri-specific paths let log_path_option = format!("-Dlogging.file.path={}", log_dir.display()); - let java_options = vec![ + let mut java_options = vec![ "-Xmx2g", "-DBROWSER_OPEN=false", "-DSTIRLING_PDF_TAURI_MODE=true", @@ -212,10 +212,16 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: & "-Dserver.port=0", // Let OS assign an available port "-Dsecurity.enableLogin=false", // Disable login for desktop mode "-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode - "-jar", - jar_path.to_str().unwrap(), ]; + // Enable the login agreement on local desktop installs when it has been provisioned. + if crate::commands::connection::login_agreement_enabled(app) { + java_options.push("-Dlegal.loginAgreement.enabled=true"); + } + + java_options.push("-jar"); + java_options.push(jar_path.to_str().unwrap()); + // Log the equivalent command for external testing let java_command = format!( "TAURI_PARENT_PID={} \"{}\" {}", diff --git a/frontend/editor/src-tauri/src/commands/connection.rs b/frontend/editor/src-tauri/src/commands/connection.rs index 068d82b732..a12040fb55 100644 --- a/frontend/editor/src-tauri/src/commands/connection.rs +++ b/frontend/editor/src-tauri/src/commands/connection.rs @@ -15,6 +15,7 @@ const FIRST_LAUNCH_KEY: &str = "setup_completed"; const CONNECTION_MODE_KEY: &str = "connection_mode"; const SERVER_CONFIG_KEY: &str = "server_config"; const LOCK_CONNECTION_KEY: &str = "lock_connection_mode"; +const LOGIN_AGREEMENT_KEY: &str = "login_agreement_enabled"; pub(crate) const UPDATE_MODE_KEY: &str = "update_mode"; /// When `true` the update mode was written by a provisioning file and cannot /// be changed from the UI. Only another provisioning file (from MDM) can @@ -182,6 +183,7 @@ pub async fn set_connection_mode( struct ProvisioningConfig { server_url: Option, lock_connection_mode: Option, + login_agreement_enabled: Option, /// Optional headless-install update policy (`"prompt"`, `"auto"`, `"disabled"`). /// When omitted the existing stored mode is left unchanged. update_mode: Option, @@ -236,14 +238,35 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin let parsed: ProvisioningConfig = serde_json::from_str(&raw) .map_err(|e| format!("Failed to parse provisioning file: {}", e))?; + // Login agreement can be provisioned independently of a server URL so it also applies to + // local, no-login desktop installs. Persist it before the server-URL handling below, which + // may early-return when no URL is present. + if let Some(login_agreement_enabled) = parsed.login_agreement_enabled { + if let Ok(store) = app_handle.store(STORE_FILE) { + store.set(LOGIN_AGREEMENT_KEY, serde_json::json!(login_agreement_enabled)); + let _ = store.save(); + } + add_log(format!( + "🧩 Provisioned login agreement enabled = {}", + login_agreement_enabled + )); + } + let server_url = parsed .server_url .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - if server_url.is_none() && parsed.update_mode.is_none() { + // Only short-circuit when there is nothing left to apply. login_agreement is handled above, + // but it must still be in this guard so a login-agreement-only file falls through to the + // deletion block at the end (otherwise the per-user file would linger and re-apply forever). + if server_url.is_none() + && parsed.update_mode.is_none() + && parsed.login_agreement_enabled.is_none() + { add_log( - "⚠️ Provisioning file has neither serverUrl nor updateMode; skipping apply".to_string(), + "⚠️ Provisioning file has no actionable fields (serverUrl/updateMode/loginAgreement); skipping apply" + .to_string(), ); return Ok(()); } @@ -335,6 +358,17 @@ pub fn apply_provisioning_if_present(app_handle: &AppHandle) -> Result<(), Strin Ok(()) } +/// Whether the login agreement was provisioned as enabled. Read by the backend launcher to pass +/// the `-Dlegal.loginAgreement.enabled` flag to the bundled JVM in local desktop mode. +pub fn login_agreement_enabled(app_handle: &AppHandle) -> bool { + app_handle + .store(STORE_FILE) + .ok() + .and_then(|store| store.get(LOGIN_AGREEMENT_KEY)) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + #[tauri::command] pub async fn is_first_launch(app_handle: AppHandle) -> Result { let store = app_handle diff --git a/frontend/editor/src-tauri/windows/wix/provisioning.wxs b/frontend/editor/src-tauri/windows/wix/provisioning.wxs index f523baa00a..6bf03d870f 100644 --- a/frontend/editor/src-tauri/windows/wix/provisioning.wxs +++ b/frontend/editor/src-tauri/windows/wix/provisioning.wxs @@ -3,6 +3,8 @@ + + - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) - (STIRLING_SERVER_URL <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (NOT ALLUSERS OR ALLUSERS=0) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) + (STIRLING_SERVER_URL <> "" OR STIRLING_LOGIN_AGREEMENT <> "" OR STIRLING_UPDATE_MODE <> "") AND (ALLUSERS=1 OR ALLUSERS=2) diff --git a/frontend/editor/src/core/components/AppLayout.tsx b/frontend/editor/src/core/components/AppLayout.tsx index 1f0e969777..38ab1f1907 100644 --- a/frontend/editor/src/core/components/AppLayout.tsx +++ b/frontend/editor/src/core/components/AppLayout.tsx @@ -1,6 +1,7 @@ import { ReactNode } from "react"; import { useBanner } from "@app/contexts/BannerContext"; import NavigationWarningModal from "@app/components/shared/NavigationWarningModal"; +import LoginAgreementModal from "@app/components/shared/LoginAgreementModal"; interface AppLayoutProps { children: ReactNode; @@ -27,6 +28,7 @@ export function AppLayout({ children }: AppLayoutProps) {
{children}
+ ); } diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx new file mode 100644 index 0000000000..8d419e7cc0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { + Box, + Button, + Divider, + Group, + Modal, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import Markdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import apiClient from "@app/services/apiClient"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useAuth } from "@app/auth/UseSession"; +import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex"; + +const ACCEPTED_STORAGE_KEY = "loginAgreementAccepted"; + +interface DisclaimerResponse { + enabled: boolean; + showInAnonymousMode: boolean; + content: string; + format: string; +} + +function readJwt(): string | null { + try { + return localStorage.getItem("stirling_jwt"); + } catch { + return null; + } +} + +// A value that changes on each fresh login so the agreement re-shows per login, but stays +// stable across page refreshes within the same logged-in tab session. +// Opaque, non-reversible digest so we never persist any token material. Only +// needs to change when the input changes (to re-trigger the disclaimer). +function digest(value: string): string { + let h = 5381; + for (let i = 0; i < value.length; i++) { + h = ((h << 5) + h + value.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +} + +function getLoginNonce(loginEnabled: boolean, userId?: string): string { + if (!loginEnabled) return "anon"; + const jwt = readJwt(); + if (jwt) return `jwt:${digest(jwt)}`; + if (userId) return `user:${userId}`; + return "session"; +} + +const markdownComponents: Components = { + // Strip react-markdown's `node` prop so it isn't spread onto the DOM element. + a({ node, ...props }) { + return ; + }, +}; + +/** + * Blocking login agreement / disclaimer shown once per login (and once per app session in + * anonymous mode). Text is fetched live for the user's current language; admins manage it via + * customFiles/disclaimer/.md. + */ +export default function LoginAgreementModal() { + const { t, i18n } = useTranslation(); + const { config } = useAppConfig(); + const { user, signOut } = useAuth(); + const { pathname } = useLocation(); + + const [opened, setOpened] = useState(false); + const [content, setContent] = useState(""); + const nonceRef = useRef("anon"); + + useEffect(() => { + if (!config) return; + // Never gate the login/auth screens themselves. pathname is a dep (not window.location) so + // the gate re-runs when the SPA navigates from /login to / after an interactive login - + // AppLayout stays mounted across that route change, so nothing else would re-trigger it. + if (pathname.startsWith("/login")) return; + + const loginEnabled = config.enableLogin !== false; + let cancelled = false; + + (async () => { + try { + const resp = await apiClient.get( + "/api/v1/config/login-disclaimer", + { + params: { lang: i18n.language }, + suppressErrorToast: true, + skipAuthRedirect: true, + }, + ); + const data = resp.data; + if (cancelled || !data?.enabled) return; + if (!loginEnabled && !data.showInAnonymousMode) return; + if (!data.content || !data.content.trim()) return; + + const nonce = getLoginNonce(loginEnabled, user?.id); + nonceRef.current = nonce; + + let accepted: string | null = null; + try { + accepted = sessionStorage.getItem(ACCEPTED_STORAGE_KEY); + } catch { + accepted = null; + } + if (accepted === nonce) return; + + setContent(data.content); + setOpened(true); + } catch { + // On fetch error (unreachable/unauthorized) fail OPEN: don't block app usage on a + // disclaimer we couldn't load. + } + })(); + + return () => { + cancelled = true; + }; + }, [config, i18n.language, user?.id, pathname]); + + const handleAccept = () => { + try { + sessionStorage.setItem(ACCEPTED_STORAGE_KEY, nonceRef.current); + } catch { + /* ignore storage errors */ + } + setOpened(false); + }; + + const handleDecline = async () => { + const loginEnabled = config?.enableLogin !== false; + if (loginEnabled) { + setOpened(false); + try { + await signOut(); + } catch { + /* ignore */ + } + window.location.assign("/login"); + } else { + // Anonymous / desktop: best-effort close the window; if that is a no-op (web), + // reload so the agreement re-blocks until accepted. + window.close(); + window.location.reload(); + } + }; + + if (!opened) return null; + + return ( + {}} + title={t("loginAgreementTitle", "Login Agreement")} + centered + size="lg" + radius="md" + closeOnClickOutside={false} + closeOnEscape={false} + withCloseButton={false} + zIndex={Z_INDEX_SIGN_IN_MODAL} + > + + + + + {content} + + + + + + + {t( + "loginAgreementProvider", + "This notice is provided by your administrator, not Stirling PDF Inc.", + )} + + + + + + + + + ); +} diff --git a/frontend/editor/src/core/tests/helpers/api-stubs.ts b/frontend/editor/src/core/tests/helpers/api-stubs.ts index ef1f4d090b..00d4d98b81 100644 --- a/frontend/editor/src/core/tests/helpers/api-stubs.ts +++ b/frontend/editor/src/core/tests/helpers/api-stubs.ts @@ -208,6 +208,12 @@ export async function mockAppApis( route.fulfill({ json: true }), ); + // Login agreement / disclaimer — disabled by default so the blocking modal + // never shows; specs exercising it register a narrower route afterwards. + await page.route("**/api/v1/config/login-disclaimer*", (route: Route) => + route.fulfill({ json: { enabled: false } }), + ); + // Footer / branding — non-critical but proxied, so stub to avoid noise await page.route("**/api/v1/ui-data/footer-info", (route: Route) => route.fulfill({ json: {} }), diff --git a/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts new file mode 100644 index 0000000000..2dccef4959 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/login-agreement-modal.spec.ts @@ -0,0 +1,139 @@ +import { test, expect, type Page, type Route } from "@playwright/test"; +import { + mockAppApis, + seedCookieConsent, + skipOnboarding, +} from "@app/tests/helpers/api-stubs"; + +/** + * The LoginAgreementModal (AppLayout) shows a blocking Accept/Decline disclaimer after login + * (and on launch in anonymous mode). Text comes from GET /api/v1/config/login-disclaimer for the + * current language, rendered as markdown; acceptance is remembered per login for the tab session. + */ + +const MARKDOWN = "## Test Disclaimer\n\nThis is **mandatory** reading."; + +interface DisclaimerStub { + enabled?: boolean; + showInAnonymousMode?: boolean; + content?: string; +} + +async function stubDisclaimer(page: Page, opts: DisclaimerStub = {}) { + const { + enabled = true, + showInAnonymousMode = true, + content = MARKDOWN, + } = opts; + await page.route("**/api/v1/config/login-disclaimer*", (route: Route) => + route.fulfill({ + json: { enabled, showInAnonymousMode, content, format: "markdown" }, + }), + ); +} + +async function setUpLoggedIn(page: Page, disclaimer: DisclaimerStub = {}) { + await seedCookieConsent(page); + await skipOnboarding(page); + await page.addInitScript(() => { + localStorage.setItem( + "stirling_jwt", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature", + ); + }); + await mockAppApis(page, { + enableLogin: true, + user: { id: 1, username: "admin", email: "admin", roles: ["ROLE_ADMIN"] }, + }); + await stubDisclaimer(page, disclaimer); +} + +test.describe("Login agreement modal", () => { + test("shows a blocking disclaimer with rendered markdown after login", async ({ + page, + }) => { + await setUpLoggedIn(page); + await page.goto("/"); + + await expect( + page.getByText("Login Agreement", { exact: true }).first(), + ).toBeVisible({ timeout: 15_000 }); + // Markdown is rendered (heading + bold), not shown as raw text. + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeVisible(); + await expect(page.getByText("mandatory")).toBeVisible(); + await expect(page.getByRole("button", { name: "Accept" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Decline" })).toBeVisible(); + }); + + test("Escape does not dismiss the modal (blocking)", async ({ page }) => { + await setUpLoggedIn(page); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeVisible({ timeout: 15_000 }); + + await page.keyboard.press("Escape"); + await page.waitForTimeout(400); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeVisible(); + }); + + test("Accept dismisses and it does not reappear on reload (once per login)", async ({ + page, + }) => { + await setUpLoggedIn(page); + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeVisible({ timeout: 15_000 }); + + await page.getByRole("button", { name: "Accept" }).click(); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeHidden(); + + await page.reload(); + await page.waitForTimeout(1000); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeHidden(); + }); + + test("does not show when the feature is disabled", async ({ page }) => { + await setUpLoggedIn(page, { enabled: false, content: "" }); + await page.goto("/"); + // App is usable; modal never appears. + await page.waitForTimeout(1500); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeHidden(); + }); + + test("shows in anonymous (no-login) mode when allowed", async ({ page }) => { + await seedCookieConsent(page); + await skipOnboarding(page); + await mockAppApis(page, { enableLogin: false }); + await stubDisclaimer(page, { showInAnonymousMode: true }); + await page.goto("/"); + + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeVisible({ timeout: 15_000 }); + }); + + test("does not show in anonymous mode when suppressed", async ({ page }) => { + await seedCookieConsent(page); + await skipOnboarding(page); + await mockAppApis(page, { enableLogin: false }); + await stubDisclaimer(page, { showInAnonymousMode: false }); + await page.goto("/"); + + await page.waitForTimeout(1500); + await expect( + page.getByRole("heading", { name: "Test Disclaimer" }), + ).toBeHidden(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx index b86d554014..4d22a8d304 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx @@ -8,6 +8,8 @@ import { Loader, Group, Alert, + Switch, + Divider, } from "@mantine/core"; import WarningIcon from "@mui/icons-material/Warning"; import { alert } from "@app/components/toast"; @@ -19,6 +21,7 @@ import PendingBadge from "@app/components/shared/config/PendingBadge"; import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; +import LoginAgreementEditor from "@app/components/shared/config/configSections/LoginAgreementEditor"; interface LegalSettingsData { termsAndConditions?: string; @@ -26,6 +29,11 @@ interface LegalSettingsData { accessibilityStatement?: string; cookiePolicy?: string; impressum?: string; + loginAgreement?: { + enabled?: boolean; + showInAnonymousMode?: boolean; + fallbackText?: string; + }; } export default function AdminLegalSection() { @@ -48,6 +56,23 @@ export default function AdminLegalSection() { isFieldPending, } = useAdminSettings({ sectionName: "legal", + // The flat legal URL fields save through the section endpoint as before; the nested + // loginAgreement object is flattened to dotted keys sent via the global settings endpoint + // (updateSettingsTransactional), which merges into the existing node. Saving a partial + // nested object through the section endpoint would replace the whole loginAgreement node + // and drop the sibling keys the UI didn't touch (e.g. fallbackText). fallbackText is not + // edited here, so it is deliberately omitted and left untouched. + saveTransformer: (current: LegalSettingsData) => { + const { loginAgreement, ...flat } = current; + const deltaSettings: Record = {}; + if (loginAgreement) { + deltaSettings["legal.loginAgreement.enabled"] = + loginAgreement.enabled ?? false; + deltaSettings["legal.loginAgreement.showInAnonymousMode"] = + loginAgreement.showInAnonymousMode ?? true; + } + return { sectionData: flat, deltaSettings }; + }, }); useEffect(() => { @@ -264,6 +289,72 @@ export default function AdminLegalSection() { + + + +
+ + {t( + "admin.settings.legal.loginAgreement.title", + "Login Agreement", + )} + + + {t( + "admin.settings.legal.loginAgreement.description", + "Show a disclaimer users must accept after logging in. The text follows each user's language.", + )} + +
+ + + setSettings({ + ...settings, + loginAgreement: { + ...settings.loginAgreement, + enabled: e.currentTarget.checked, + }, + }) + } + disabled={!loginEnabled} + /> + + + setSettings({ + ...settings, + loginAgreement: { + ...settings.loginAgreement, + showInAnonymousMode: e.currentTarget.checked, + }, + }) + } + disabled={!loginEnabled} + /> + + + {t( + "admin.settings.legal.loginAgreement.restartNote", + "Enabling or disabling the agreement applies after a restart, like other settings. Text edits below apply immediately.", + )} + + + + + +
+
({ value, label: `${label} (${value})` }), +); + +interface LoginAgreementEditorProps { + disabled?: boolean; +} + +/** + * Per-language editor for the login agreement markdown. Reads/writes + * customFiles/disclaimer/.md directly via the admin endpoint, so saved text takes effect + * on the next login without a restart (unlike the enable flags, which go through settings). + */ +export default function LoginAgreementEditor({ + disabled, +}: LoginAgreementEditorProps) { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const initialLocale = Object.prototype.hasOwnProperty.call( + supportedLanguages, + i18n.language, + ) + ? i18n.language + : "en-US"; + + const [locale, setLocale] = useState(initialLocale); + const [content, setContent] = useState(""); + const [loadedContent, setLoadedContent] = useState(""); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + try { + const resp = await apiClient.get( + `/api/v1/admin/login-agreement/${encodeURIComponent(locale)}`, + ); + if (cancelled) return; + const loaded = resp.data?.content ?? ""; + setContent(loaded); + setLoadedContent(loaded); + setLoadFailed(false); + } catch { + if (!cancelled) { + // Surface the failure instead of silently showing a blank editor (which is + // indistinguishable from "no file yet"), and keep Save disabled so a stale buffer + // can't overwrite a file that exists but failed to load. + setContent(""); + setLoadedContent(""); + setLoadFailed(true); + alert({ + alertType: "error", + title: t("admin.error", "Error"), + body: t( + "admin.settings.legal.loginAgreement.loadError", + "Failed to load the agreement for {{locale}}. Switch language and back to retry.", + { locale }, + ), + }); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [locale, t]); + + const dirty = content !== loadedContent; + + const handleLocaleChange = (value: string | null) => { + if (!value || value === locale) return; + // Don't silently discard unsaved markdown when switching languages. + if ( + dirty && + !window.confirm( + t( + "admin.settings.legal.loginAgreement.discardConfirm", + "You have unsaved changes that will be lost. Switch language anyway?", + ), + ) + ) { + return; + } + setLocale(value); + }; + + // Jump to the General settings section where the default locale (the fallback used when a + // language has no file) is configured. Guard unsaved edits like the language switch. + const goToDefaultLocaleSetting = () => { + if ( + dirty && + !window.confirm( + t( + "admin.settings.legal.loginAgreement.discardConfirm", + "You have unsaved changes that will be lost. Switch language anyway?", + ), + ) + ) { + return; + } + navigate("/settings/adminGeneral"); + }; + + const handleSave = async () => { + setSaving(true); + try { + await apiClient.put( + `/api/v1/admin/login-agreement/${encodeURIComponent(locale)}`, + { content }, + ); + setLoadedContent(content); + alert({ + alertType: "success", + title: t("admin.settings.legal.loginAgreement.saved", "Saved"), + body: t( + "admin.settings.legal.loginAgreement.savedBody", + "Login agreement updated for {{locale}}", + { locale }, + ), + }); + } catch (_error) { + alert({ + alertType: "error", + title: t("admin.error", "Error"), + body: t("admin.settings.saveError", "Failed to save settings"), + }); + } finally { + setSaving(false); + } + }; + + return ( + + +