diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 5e85976937..905f2feed9 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -1,12 +1,12 @@ build: &build - build.gradle - - app/(common|core|proprietary)/build.gradle + - app/(common|core|proprietary|saas)/build.gradle - Taskfile.yml - .taskfiles/backend.yml openapi: &openapi - *build - - app/(common|core|proprietary)/src/main/java/** + - app/(common|core|proprietary|saas)/src/main/java/** docker-base: &docker-base - docker/base/Dockerfile @@ -23,9 +23,9 @@ docker: &docker - *docker-base project: &project - - app/(common|core|proprietary)/src/(main|test)/java/** + - app/(common|core|proprietary|saas)/src/(main|test)/java/** - *build - - "app/(common|core|proprietary)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" + - "app/(common|core|proprietary|saas)/src/(main|test)/resources/**/!(messages_*.properties|*.md)*" - exampleYmlFiles/** - gradle/** - libs/** @@ -82,7 +82,7 @@ tauri: &tauri # tool surfaces it generates models from. engine: &engine - engine/** - - app/(common|core|proprietary)/src/main/java/** + - app/(common|core|proprietary|saas)/src/main/java/** - .github/workflows/ai-engine.yml - Taskfile.yml - .taskfiles/engine.yml diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 9eb38bde3d..ad3b431e57 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -63,6 +63,7 @@ labels: files: - 'app/core/src/main/resources/static/.*' - 'app/proprietary/src/main/resources/static/.*' + - 'app/saas/src/main/resources/static/.*' - 'frontend/**' - 'frontend/.*' - 'frontend/**/.*' @@ -83,6 +84,7 @@ labels: - 'app/common/src/main/java/.*.java' - 'app/proprietary/src/main/java/.*.java' - 'app/core/src/main/java/.*.java' + - 'app/saas/src/main/java/.*.java' - label: 'Back End' files: @@ -90,6 +92,9 @@ labels: - 'app/core/src/main/java/stirling/software/SPDF/controller/.*' - 'app/core/src/main/resources/settings.yml.template' - 'app/core/src/main/resources/application.properties' + - 'app/proprietary/src/main/resources/application-proprietary.properties' + - 'app/saas/src/main/resources/application-dev.properties' + - 'app/saas/src/main/resources/application-saas.properties' - 'app/core/src/main/resources/banner.txt' - 'app/core/src/main/resources/static/python/png_to_webp.py' - 'app/core/src/main/resources/static/python/split_photos.py' @@ -153,6 +158,7 @@ labels: - 'app/common/src/test/.*' - 'app/proprietary/src/test/.*' - 'app/core/src/test/.*' + - 'app/saas/src/test/.*' - 'testing/.*' - '.github/workflows/scorecards.yml' - 'exampleYmlFiles/test_cicd.yml' @@ -171,3 +177,4 @@ labels: - 'app/common/build.gradle' - 'app/proprietary/build.gradle' - 'app/core/build.gradle' + - 'app/saas/build.gradle' diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 173b4db9df..a3573a49df 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -256,7 +256,7 @@ jobs: - name: Build and push V2 image (Docker fork fallback) if: env.USE_DEPOT != 'true' && steps.check-image.outputs.exists == 'false' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/embedded/Dockerfile diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index e478f95c89..5a4ea8deef 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -270,7 +270,7 @@ jobs: - name: Build and push PR-specific image (Docker fork fallback) if: env.USE_DEPOT != 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/embedded/Dockerfile @@ -296,7 +296,7 @@ jobs: - name: Build and push engine image (Docker fork fallback) if: env.USE_DEPOT != 'true' && needs.check-comment.outputs.enable_prototypes == 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ./engine file: ./engine/Dockerfile diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 37a42c419f..ef0dd20aba 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -47,7 +47,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index b984f7d47d..dcd8c032df 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -29,7 +29,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 46ce829a87..a27ea4ec02 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -34,7 +34,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index ce792bcadc..18772fe819 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -51,7 +51,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 4b5ca8b674..9e8eeb8c8c 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -37,7 +37,7 @@ jobs: distribution: temurin - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 850dcb12e4..0a4f502166 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -121,7 +121,7 @@ jobs: - name: Build and push frontend image (Docker fork fallback) if: env.USE_DEPOT != 'true' && steps.check-frontend.outputs.exists == 'false' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/frontend/Dockerfile @@ -150,7 +150,7 @@ jobs: - name: Build and push backend image (Docker fork fallback) if: env.USE_DEPOT != 'true' && steps.check-backend.outputs.exists == 'false' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/backend/Dockerfile diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 155b990b32..945f4883cb 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -50,7 +50,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f5fb743a3f..9e8a65f02c 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -61,7 +61,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 57d0935d1d..c6fbe3f7ad 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -85,7 +85,7 @@ jobs: - name: Build and push base image id: build-push-base - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} context: docker/base diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 75b1aa60c8..eac1fa49fc 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -66,7 +66,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches @@ -145,7 +145,7 @@ jobs: id: build-push-latest # Empty-tag guard: build-push-action errors when asked to push with no tags. if: env.RUN_MAIN_APP == 'true' && steps.meta.outputs.tags != '' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} context: . @@ -192,7 +192,7 @@ jobs: - name: Build and push Unified Dockerfile (fat variant) id: build-push-fat - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-fat.outputs.tags != '' with: builder: ${{ steps.buildx.outputs.name }} @@ -236,7 +236,7 @@ jobs: - name: Build and push Unified Dockerfile (ultra-lite variant) id: build-push-lite - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 if: env.RUN_MAIN_APP == 'true' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain' && steps.meta-lite.outputs.tags != '' with: builder: ${{ steps.buildx.outputs.name }} @@ -365,7 +365,7 @@ jobs: - name: Build and push unoserver image id: build-push-unoserver if: env.RUN_UNOSERVER == 'true' && steps.unoserverDecision.outputs.mode != 'skip' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} context: . diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index b6a081bb1e..c91ea0435a 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -95,7 +95,7 @@ jobs: distribution: "temurin" - name: Cache Gradle dependency artifacts - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/wrapper @@ -186,7 +186,7 @@ jobs: # (multi-platform + gha cache) against the published base image. - name: Build ${{ matrix.docker-rev }} (Docker fork fallback) if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} context: . @@ -259,7 +259,7 @@ jobs: - name: Build docker/unoserver/Dockerfile (Docker fork fallback) if: env.USE_DEPOT != 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: builder: ${{ steps.buildx.outputs.name }} context: . diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index e9774694e7..ec9b658326 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -95,7 +95,7 @@ jobs: - name: Build and push test image (Docker fork fallback) if: env.USE_DEPOT != 'true' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: ./docker/embedded/Dockerfile diff --git a/.imgbotconfig b/.imgbotconfig new file mode 100644 index 0000000000..720b938475 --- /dev/null +++ b/.imgbotconfig @@ -0,0 +1,5 @@ +{ + "ignoredFiles": [ + "frontend/editor/src-tauri/icons/icon.png" + ] +} diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 4f87226f93..b402df7d1b 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -80,6 +80,12 @@ tasks: OPEN: '{{.OPEN | default ""}}' env: BACKEND_URL: '{{.BACKEND_URL}}' + # Dev-only browser-tab label so concurrent worktrees are distinguishable. + # Only the worktree folder basename (e.g. "wt1") is exposed — never the + # full path, hostname, or user. Consumed at dev-serve time by vite.config + # and dropped from production builds. + STIRLING_DEV_LABEL: + sh: basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cmds: - npx vite editor --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} 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 9ed2610f51..d2f0472510 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 @@ -246,6 +246,14 @@ public class ApplicationProperties { * and paused runs are kept regardless of age. */ private int runExpiryMinutes = 30; + + /** + * Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or + * private address. Off by default so a user-supplied endpoint cannot be pointed at internal + * services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other + * in-network object store. + */ + private boolean allowPrivateS3Endpoints = false; } @Data diff --git a/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java b/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java new file mode 100644 index 0000000000..9ef4cb0511 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/AutomationRunContext.java @@ -0,0 +1,56 @@ +package stirling.software.common.service; + +/** + * Thread-scoped correlation id for one automation run — a single pipeline, policy, or AI-workflow + * execution over its input file(s). + * + *

Automations dispatch each tool step as a separate internal loopback POST via {@link + * InternalApiClient}. The orchestrator opens a run scope around its dispatch loop; {@code + * InternalApiClient} reads {@link #current()} and stamps it on every sub-step request as {@link + * #RUN_ID_HEADER}. The SaaS PAYG interceptor uses that header so all sub-steps of ONE run group + * into a single charge, while two separate runs that happen to touch identical bytes stay + * distinct charges (the old content+time-window grouping merged them). + * + *

Sub-steps dispatch synchronously on the orchestrator's own thread (loopback {@code + * RestTemplate}), so this ThreadLocal is visible to {@code InternalApiClient}. The id then crosses + * to the receiving request thread via the HTTP header — never via this ThreadLocal. + * + *

No-op when the id is absent (a standalone tool call): the interceptor treats a missing run id + * as "its own charge", which is exactly what a one-off call should be. + */ +public final class AutomationRunContext { + + /** Header carrying the run id on internal sub-step dispatches. */ + public static final String RUN_ID_HEADER = "X-Stirling-Run-Id"; + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private AutomationRunContext() {} + + /** + * Opens a run scope on the current thread. Returns an {@link AutoCloseable} that restores the + * previously-active id (nesting-safe) — use in try-with-resources around the dispatch loop. + */ + public static Scope open(String runId) { + String previous = CURRENT.get(); + CURRENT.set(runId); + return () -> { + if (previous == null) { + CURRENT.remove(); + } else { + CURRENT.set(previous); + } + }; + } + + /** The run id active on this thread, or {@code null} when not inside a run scope. */ + public static String current() { + return CURRENT.get(); + } + + /** AutoCloseable whose {@link #close()} declares no checked exception. */ + public interface Scope extends AutoCloseable { + @Override + void close(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index 8df2d5e410..63d577ca85 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -8,6 +8,7 @@ import java.nio.file.Files; import java.time.Duration; import java.util.regex.Pattern; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.env.Environment; import org.springframework.core.io.FileSystemResource; @@ -60,6 +61,17 @@ public class InternalApiClient { */ public static final String AUTOMATION_HEADER = "X-Stirling-Automation"; + /** + * Header carrying the parent policy's name onto each sub-step dispatch, read from MDC key + * {@link #POLICY_NAME_MDC_KEY} (set by the policy runner on the worker thread). Lets the audit + * layer attribute a tool step to the policy that ran it, instead of showing it as a bare direct + * call. + */ + public static final String POLICY_NAME_HEADER = "X-Stirling-Policy-Name"; + + /** MDC key the policy runner stamps with the running policy's name; forwarded as a header. */ + public static final String POLICY_NAME_MDC_KEY = "auditPolicyName"; + private final ServletContext servletContext; private final UserServiceInterface userService; private final TempFileManager tempFileManager; @@ -111,6 +123,27 @@ public class InternalApiClient { // step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because // every caller of this dispatcher is an automation surface by design. headers.add(AUTOMATION_HEADER, "true"); + // Propagate the current automation run id (set by the orchestrator around its dispatch + // loop) so the PAYG interceptor groups every sub-step of this one run into a single charge, + // and never merges two separate runs that happen to touch identical bytes. Absent → the + // receiving call is treated as standalone. See AutomationRunContext. + String runId = AutomationRunContext.current(); + if (runId != null && !runId.isEmpty()) { + headers.add(AutomationRunContext.RUN_ID_HEADER, runId); + } + + // Forward the parent policy name (set in MDC by the policy runner) so the audited sub-step + // ties back to its policy. Single-line, length-capped: it becomes an HTTP header value. + String policyName = MDC.get(POLICY_NAME_MDC_KEY); + if (policyName != null && !policyName.isBlank()) { + String safe = policyName.replaceAll("[\\r\\n]", " ").trim(); + if (safe.length() > 200) { + safe = safe.substring(0, 200); + } + if (!safe.isEmpty()) { + headers.add(POLICY_NAME_HEADER, safe); + } + } // A no-file ai/tools call (e.g. create-pdf-from-html-agent) sends only string params, so // without this RestTemplate would use urlencoded instead of the multipart the controller diff --git a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java index 81b45aed09..438481c212 100644 --- a/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java +++ b/app/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -144,8 +144,10 @@ public class TempFileCleanupService { int directoriesDeletedCount = 0; for (Path directory : registry.getTempDirectories()) { try { - if (Files.exists(directory)) { + if (Files.exists(directory) + && shouldDeleteRegisteredDirectory(directory, maxAgeMillis)) { GeneralUtils.deleteDirectory(directory); + registry.unregisterDirectory(directory); directoriesDeletedCount++; log.debug("Cleaned up temporary directory: {}", directory); } @@ -275,6 +277,21 @@ public class TempFileCleanupService { return totalDeletedCount.get(); } + private boolean shouldDeleteRegisteredDirectory(Path directory, long maxAgeMillis) { + if (maxAgeMillis <= 0) { + return true; + } + + try { + long currentTime = System.currentTimeMillis(); + long lastModified = Files.getLastModifiedTime(directory).toMillis(); + return (currentTime - lastModified) > maxAgeMillis; + } catch (IOException e) { + log.debug("Could not check directory age, skipping cleanup: {}", directory, e); + return false; + } + } + /** Get the system temp directory path based on configuration or system property. */ private Path getSystemTempPath() { String systemTempDir = diff --git a/app/common/src/main/java/stirling/software/common/util/TempFileManager.java b/app/common/src/main/java/stirling/software/common/util/TempFileManager.java index ff31975837..0d977e7d06 100644 --- a/app/common/src/main/java/stirling/software/common/util/TempFileManager.java +++ b/app/common/src/main/java/stirling/software/common/util/TempFileManager.java @@ -155,6 +155,7 @@ public class TempFileManager { if (directory != null && Files.isDirectory(directory)) { try { GeneralUtils.deleteDirectory(directory); + registry.unregisterDirectory(directory); log.debug("Deleted temp directory: {}", directory.toString()); } catch (IOException e) { log.warn("Failed to delete temp directory: {}", directory.toString(), e); diff --git a/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java b/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java index ab1304f5e4..84a3c12272 100644 --- a/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java +++ b/app/common/src/main/java/stirling/software/common/util/TempFileRegistry.java @@ -85,6 +85,18 @@ public class TempFileRegistry { return directory; } + /** + * Unregister a temporary directory from the registry. + * + * @param directory The directory to unregister + */ + public void unregisterDirectory(Path directory) { + if (directory != null) { + tempDirectories.remove(directory); + log.debug("Unregistered temp directory: {}", directory.toString()); + } + } + /** * Register a third-party temporary file that requires special handling. * diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java index 802dc2eace..cfbe950574 100644 --- a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java @@ -176,11 +176,13 @@ class TempFileCleanupServiceMoreTest { class ScheduledCleanup { @Test - @DisplayName("deletes registered temp directories and reports counts") + @DisplayName("deletes stale registered temp directories and reports counts") void deletesRegisteredDirectories() throws IOException { when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2); Path regDir = Files.createDirectories(tempDir.resolve("registeredDir")); Files.createFile(regDir.resolve("inside.txt")); + Files.setLastModifiedTime( + regDir, FileTime.fromMillis(System.currentTimeMillis() - 2L * 60 * 60 * 1000)); Set dirs = new HashSet<>(); dirs.add(regDir); when(registry.getTempDirectories()).thenReturn(dirs); @@ -193,6 +195,22 @@ class TempFileCleanupServiceMoreTest { verify(tempFileManager).cleanupOldTempFiles(anyLong()); } + @Test + @DisplayName("keeps a fresh registered temp directory") + void keepsFreshRegisteredDirectory() throws IOException { + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0); + Path regDir = Files.createDirectories(tempDir.resolve("freshRegisteredDir")); + Files.createFile(regDir.resolve("inside.txt")); + Set dirs = new HashSet<>(); + dirs.add(regDir); + when(registry.getTempDirectories()).thenReturn(dirs); + lenient().when(registry.contains(any(File.class))).thenReturn(false); + + withIsolatedUserHome(cleanupService::scheduledCleanup); + + assertThat(Files.exists(regDir)).isTrue(); + } + @Test @DisplayName("skips a registered directory that no longer exists") void skipsMissingRegisteredDirectory() { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java index c57a43f27f..c4f1c76f5e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessor.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; +import java.util.UUID; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -27,6 +28,7 @@ import stirling.software.SPDF.model.PipelineConfig; import stirling.software.SPDF.model.PipelineOperation; import stirling.software.SPDF.model.PipelineResult; import stirling.software.SPDF.service.ApiDocService; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.InternalApiClient; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.ZipExtractionUtils; @@ -71,6 +73,17 @@ public class PipelineProcessor { PipelineResult runPipelineAgainstFiles(List outputFiles, PipelineConfig config) throws Exception { + // One pipeline execution = one automation run. Scope a run id so every tool sub-step + // dispatched via InternalApiClient groups into a single charge on the SaaS billing side + // (see AutomationRunContext); pipeline steps run synchronously on this thread. + try (AutomationRunContext.Scope ignored = + AutomationRunContext.open(UUID.randomUUID().toString())) { + return runPipelineAgainstFilesInternal(outputFiles, config); + } + } + + private PipelineResult runPipelineAgainstFilesInternal( + List outputFiles, PipelineConfig config) throws Exception { PipelineResult result = new PipelineResult(); ByteArrayOutputStream logStream = new ByteArrayOutputStream(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index 9c9bce3bd2..be2d10e6e2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -133,11 +133,17 @@ public class RedactController { + "Users can provide text patterns to redact, with options for regex and whole word matching. " + "Input:PDF Output:PDF Type:SISO") public ResponseEntity redactPdf(@ModelAttribute RedactPdfRequest request) { - String[] listOfText = request.getListOfText().split("\n"); + String rawListOfText = request.getListOfText(); boolean useRegex = Boolean.TRUE.equals(request.getUseRegex()); boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch()); - if (listOfText.length == 0 || (listOfText.length == 1 && listOfText[0].trim().isEmpty())) { + if (rawListOfText == null || rawListOfText.trim().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.redaction.no.patterns", "No text patterns provided for redaction"); + } + + String[] listOfText = rawListOfText.split("\n"); + if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) { throw ExceptionUtils.createIllegalArgumentException( "error.redaction.no.patterns", "No text patterns provided for redaction"); } diff --git a/app/core/src/main/resources/static/api-wordmark.svg b/app/core/src/main/resources/static/api-wordmark.svg index deac1ee165..3658102f90 100644 --- a/app/core/src/main/resources/static/api-wordmark.svg +++ b/app/core/src/main/resources/static/api-wordmark.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/redact-auto.svg b/app/core/src/main/resources/static/images/redact-auto.svg index 5c87b738ef..7c29b0fc4b 100644 --- a/app/core/src/main/resources/static/images/redact-auto.svg +++ b/app/core/src/main/resources/static/images/redact-auto.svg @@ -1,18 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/rename.svg b/app/core/src/main/resources/static/images/rename.svg index 6b19bec208..97946a086d 100644 --- a/app/core/src/main/resources/static/images/rename.svg +++ b/app/core/src/main/resources/static/images/rename.svg @@ -1,13 +1 @@ - - - - rename - - - - - - - - - +rename \ No newline at end of file diff --git a/app/core/src/main/resources/static/images/stirling-logo-white.png b/app/core/src/main/resources/static/images/stirling-logo-white.png index 8018b0771c..c66dcbf3f0 100644 Binary files a/app/core/src/main/resources/static/images/stirling-logo-white.png and b/app/core/src/main/resources/static/images/stirling-logo-white.png differ diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java index 6eafecc1a5..150a2355de 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java @@ -299,6 +299,18 @@ class RedactControllerMoreTest { verify(pdfDocumentFactory, never()).load(any(MultipartFile.class)); } + @Test + @DisplayName("null listOfText throws an illegal-argument error before any load") + void nullPatternsThrows() throws Exception { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(new byte[] {1, 2, 3})); + request.setListOfText(null); + + assertThatThrownBy(() -> controller.redactPdf(request)) + .isInstanceOf(RuntimeException.class); + verify(pdfDocumentFactory, never()).load(any(MultipartFile.class)); + } + @Test @DisplayName("null file input is reported as a failure") void nullFileThrows() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java index f42a0f29c1..12b2787b75 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditAspect.java @@ -142,6 +142,9 @@ public class AuditAspect { auditService.addTimingData( auditData, startTime, resp, auditedAnnotation.level(), isHttpRequest); + // Merge controller-set policy context + the internal-automation marker onto the event. + auditService.addAutomationContext(auditData, req); + // Resolve the event type based on annotation and context String httpMethod = null; String path = null; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java new file mode 100644 index 0000000000..650727f5ef --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditContext.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.audit; + +/** + * Request-scoped keys a controller can set to enrich its own audit event with context the generic + * aspect can't infer from the HTTP request alone (e.g. the policy a pipeline run belongs to). The + * aspect copies these into the audit data in its {@code finally} block, after the controller body + * has run. See {@code AuditService#addAutomationContext}. + */ +public final class AuditContext { + + /** Request attribute: the name of the policy/pipeline a run executes. */ + public static final String REQ_ATTR_POLICY_NAME = "stirling.audit.policyName"; + + /** Request attribute: the ordered tool endpoint paths a run executes. */ + public static final String REQ_ATTR_POLICY_STEPS = "stirling.audit.policySteps"; + + private AuditContext() {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index c59c6f3721..2f75b755e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -205,6 +205,10 @@ public class ControllerAuditAspect { // Call auditService but with isHttpRequest=true to skip additional timing auditService.addTimingData(data, start, resp, level, true); + // Merge controller-set policy context + the internal-automation marker (set after + // the body ran, so it must happen here rather than with the pre-proceed HTTP data). + auditService.addAutomationContext(data, req); + // Resolve the event type using the unified method AuditEventType eventType = auditService.resolveEventType( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java index eacdaf32c0..d3aab54c76 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/s3/S3Clients.java @@ -133,29 +133,45 @@ public final class S3Clients { * storage.s3.allow-private-endpoints=true}. */ static void validateEndpointHost(URI endpoint, boolean allowPrivate) { + validateEndpointHost( + endpoint, + allowPrivate, + "storage.s3.endpoint", + "set storage.s3.allow-private-endpoints=true to opt in" + + " (e.g. for MinIO or in-cluster S3)."); + } + + /** + * The same private-address guard for S3 endpoints configured outside the {@code storage.s3.*} + * block (e.g. per-source policy config), with the setting named in messages supplied by the + * caller. + */ + public static void validateEndpointHost( + URI endpoint, boolean allowPrivate, String settingName, String optInHint) { if (allowPrivate) { return; } String host = endpoint.getHost(); if (host == null || host.isBlank()) { - throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint); + throw new IllegalStateException(settingName + " must include a host: " + endpoint); } InetAddress[] addresses; try { addresses = InetAddress.getAllByName(host); } catch (UnknownHostException e) { throw new IllegalStateException( - "Unable to resolve storage.s3.endpoint host '" + host + "'", e); + "Unable to resolve " + settingName + " host '" + host + "'", e); } for (InetAddress address : addresses) { if (isPrivateOrLocal(address)) { throw new IllegalStateException( - "storage.s3.endpoint host '" + settingName + + " host '" + host + "' resolves to private/link-local address " + address.getHostAddress() - + "; set storage.s3.allow-private-endpoints=true to opt in" - + " (e.g. for MinIO or in-cluster S3)."); + + "; " + + optInHint); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java index 57b74362ec..8f95920e5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/FleetUsageController.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import org.springframework.context.annotation.Profile; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,6 +39,9 @@ import stirling.software.proprietary.security.database.repository.UserRepository @PreAuthorize("hasRole('ADMIN')") @RequiredArgsConstructor @EnterpriseEndpoint +// Self-hosted only: counts are server-wide. On SaaS this endpoint is owned by the team-scoped +// SaasFleetUsageController (@Profile("saas")) so one backend can't leak another tenant's usage. +@Profile("!saas") public class FleetUsageController { private final PersistentAuditEventRepository auditRepository; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java new file mode 100644 index 0000000000..7bb6190220 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverter.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.integration.crypto; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped: + * writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is, + * so pre-encryption rows keep loading and become encrypted on their next save. The discrimination + * is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64 + * alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the + * caller's parser instead of failing here. + */ +@Converter +public class LenientEncryptedStringConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(String attribute) { + return CredentialEncryption.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(String dbData) { + try { + return CredentialEncryption.decrypt(dbData); + } catch (IllegalArgumentException | IllegalStateException e) { + // Not ciphertext: legacy plaintext from before encryption shipped. + return dbData; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java index c924218e3a..835344e81d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/audit/InfraAuditSummary.java @@ -17,6 +17,9 @@ public class InfraAuditSummary { @Schema(description = "Total events in the returned window", example = "40") private int totalEvents; + @Schema(description = "Policy-run events", example = "3") + private int policy; + @Schema(description = "Processing-category events", example = "24") private int processing; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 9494317751..e2c89f6a54 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -32,6 +34,7 @@ import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -42,14 +45,18 @@ import stirling.software.common.model.job.JobResponse; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.engine.SweepOutcome; import stirling.software.proprietary.policy.ledger.ProcessedLedger; +import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; +import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; @@ -58,12 +65,15 @@ import stirling.software.proprietary.policy.model.PolicyRunView; import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse; import stirling.software.proprietary.policy.overview.PolicyOverviewService; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.EditorSource; import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.store.PolicyStore; import stirling.software.proprietary.policy.trigger.PolicyTrigger; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; import stirling.software.proprietary.policy.trigger.TriggerInfo; +import stirling.software.proprietary.util.SecretMasker; /** * Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code @@ -83,6 +93,7 @@ public class PolicyController { private final PolicyStore policyStore; private final SourceStore sourceStore; private final SourceAccessGuard sourceAccessGuard; + private final SourceDocCounter docCounter; private final PolicyValidator policyValidator; private final PolicyAccessGuard policyAccessGuard; private final PolicyManagementAuthority policyManagementAuthority; @@ -107,11 +118,13 @@ public class PolicyController { @RequestPart("json") PipelineDefinition definition, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { + stampPolicyAudit(definition); requireRunnable(definition); PolicyInputs inputs = toInputs(files); - String runId = - policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId(); - return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); + PolicyRunHandle handle = + policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); + recordEditorDocs(inputs); + return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null)); } @PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -125,6 +138,7 @@ public class PolicyController { @RequestPart("json") PipelineDefinition definition, @Valid @ModelAttribute PolicyRunFiles files) throws IOException { + stampPolicyAudit(definition); requireRunnable(definition); PolicyInputs inputs = toInputs(files); @@ -133,6 +147,7 @@ public class PolicyController { emitter.onError(e -> log.warn("Policy run SSE emitter error", e)); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter)); + recordEditorDocs(inputs); // whenComplete runs on the worker thread after the run finishes, so the terminal event // never races the step events. handle.completion() @@ -202,7 +217,7 @@ public class PolicyController { + " assigned; returns the stored policy with its id.") public ResponseEntity savePolicy(@RequestBody Policy policy) { requirePolicyEditingAllowed(); - Policy owned = resolveOwnership(policy); + Policy owned = withStoredOutputSecrets(resolveOwnership(policy)); requireAccessibleSources(owned); try { policyValidator.validate(owned); @@ -213,7 +228,7 @@ public class PolicyController { // Re-sync trigger registrations now so a new/changed folder-watch policy starts being // watched immediately instead of after the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.ok(saved); + return ResponseEntity.ok(withMaskedOutputSecrets(saved)); } @PutMapping("/order") @@ -282,6 +297,50 @@ public class PolicyController { teamId); } + /** Output secrets never leave the server: reads return the redaction sentinel instead. */ + private static Policy withMaskedOutputSecrets(Policy policy) { + return withOutput( + policy, + new OutputSpec( + policy.output().type(), SecretMasker.mask(policy.output().options()))); + } + + /** + * An edit that round-trips a masked read sends output secrets back as the sentinel; restore + * them from the stored policy so saving without re-typing keeps them (validation then runs + * against the real values). + */ + private Policy withStoredOutputSecrets(Policy incoming) { + if (incoming.id() == null || incoming.id().isBlank()) { + return incoming; + } + return policyStore + .get(incoming.id()) + .map( + existing -> + withOutput( + incoming, + new OutputSpec( + incoming.output().type(), + SecretMasker.restoreRedacted( + incoming.output().options(), + existing.output().options())))) + .orElse(incoming); + } + + private static Policy withOutput(Policy policy, OutputSpec output) { + return new Policy( + policy.id(), + policy.name(), + policy.owner(), + policy.enabled(), + policy.trigger(), + policy.sourceIds(), + policy.steps(), + output, + policy.teamId()); + } + /** * Creating, editing, pausing/resuming, and deleting policies requires the editor role for the * caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global @@ -306,9 +365,14 @@ public class PolicyController { @GetMapping @Operation( summary = "List policies", - description = "Lists the policies belonging to the caller's team.") + description = + "Lists the policies belonging to the caller's team. Secret-bearing output" + + " options are returned as a redaction sentinel, never their stored" + + " values.") public List listPolicies() { - return policyAccessGuard.visibleFrom(policyStore); + return policyAccessGuard.visibleFrom(policyStore).stream() + .map(PolicyController::withMaskedOutputSecrets) + .toList(); } @GetMapping("/overview") @@ -337,11 +401,17 @@ public class PolicyController { } @GetMapping("/{policyId}") - @Operation(summary = "Get a policy by id") + @Operation( + summary = "Get a policy by id", + description = + "Secret-bearing output options are returned as a redaction sentinel, never" + + " their stored values; an edit that sends the sentinel back keeps" + + " them.") public ResponseEntity getPolicy(@PathVariable String policyId) { return policyStore .get(policyId) .filter(policyAccessGuard::canAccess) + .map(PolicyController::withMaskedOutputSecrets) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -401,6 +471,7 @@ public class PolicyController { () -> new ResponseStatusException( HttpStatus.NOT_FOUND, "No policy: " + policyId)); + stampPolicyAudit(policy.toDefinition()); PolicyInputs inputs = toInputs(files); String runId = policyRunner.runWith(policy, inputs, PolicyProgressListener.NOOP).runId(); return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null)); @@ -412,9 +483,10 @@ public class PolicyController { description = "Pulls the policy's configured sources and runs the pipeline now, regardless of" + " the enabled flag (which only gates automatic triggering). Returns" - + " the ids of the runs started; poll the run-status endpoint for each." - + " Empty when the sources yielded no work to do.") - public ResponseEntity> trigger(@PathVariable String policyId) { + + " the ids of the runs started (poll the run-status endpoint for each)" + + " plus what the sweep skipped - already-processed, parked-by-failure," + + " and in-flight counts - so an empty result explains itself.") + public ResponseEntity trigger(@PathVariable String policyId) { Policy policy = policyStore .get(policyId) @@ -433,6 +505,42 @@ public class PolicyController { } } + /** + * Stamp this run's policy name and step endpoints onto the current request so the audit aspect + * can label the event as the policy it ran (rather than the generic {@code /run} endpoint) and + * record which tools it executed. No-op outside a web request. + */ + private static void stampPolicyAudit(PipelineDefinition definition) { + if (definition == null + || !(RequestContextHolder.getRequestAttributes() + instanceof ServletRequestAttributes attrs)) { + return; + } + HttpServletRequest request = attrs.getRequest(); + if (definition.name() != null && !definition.name().isBlank()) { + request.setAttribute(AuditContext.REQ_ATTR_POLICY_NAME, definition.name()); + } + List steps = + definition.steps().stream() + .map(PipelineStep::operation) + .filter(op -> op != null && !op.isBlank()) + .toList(); + if (!steps.isEmpty()) { + request.setAttribute(AuditContext.REQ_ATTR_POLICY_STEPS, steps); + } + } + + /** + * Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents + * feed the same virtual editor source as stored editor policies, counted against the caller's + * team. A run with no primary documents (generator pipeline) records nothing. + */ + private void recordEditorDocs(PolicyInputs inputs) { + docCounter.record( + EditorSource.counterKey(sourceAccessGuard.currentTeamId()), + inputs.primary().size()); + } + /** * Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the * named supporting-file store, where each asset's {@code key} is the name a step references diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index 98a6f90096..d54caedd14 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -21,7 +21,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.ResultFile; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; import stirling.software.common.service.InternalApiTimeoutException; import stirling.software.common.service.JobOwnershipService; import stirling.software.common.service.JobQueue; @@ -148,6 +150,7 @@ public class PolicyEngine { runAsPrincipal( billingPrincipal, fileOwner, + definition.name(), () -> runToCompletion(run, inputs, tracking, completion)); // One admission unit per run; steps run synchronously within it, so this gates heavy work @@ -199,63 +202,78 @@ public class PolicyEngine { PolicyProgressListener listener, CompletableFuture completion) { String runId = run.getRunId(); - try { - run.markRunning(); - PolicyExecutionResult result = - stepExecutor.execute(run.getDefinition(), inputs, listener); - OutputSpec output = run.getDefinition().output(); - List outputs = - sinkFor(output) - .deliver( - new OutputDelivery(runId, run.getPolicyId()), - result.files(), - output); - taskManager.setMultipleFileResults(runId, outputs); - taskManager.setComplete(runId); - run.complete(outputs); - } catch (PolicyInputRequiredException e) { - // Expected path: suspend rather than fail. Persist intermediates as fileIds so the run - // can resume after this worker thread is gone. - WaitState wait = suspend(e); - run.waitForInput(wait); - taskManager.addNote(runId, "Waiting for input: " + e.getMessage()); - } catch (InternalApiTimeoutException e) { - String message = toolTimeoutMessage(e); - log.error( - "Policy run {} timed out on {}: {}", - runId, - e.getEndpointPath(), - e.getMessage()); - run.fail(message); - taskManager.setError(runId, message); - } catch (RestClientResponseException e) { - // A downstream tool call returned an error status. When it's a structured entitlement - // response (401/402 with a JSON `error` sentinel), surface that code onto the run so - // the - // client can react — e.g. pop the usage-limit modal — instead of only seeing a generic - // failure. We don't interpret the code here (that would couple this module to the saas - // billing layer); we just pass it through for the client to map. Other statuses fall - // through to the generic failure below. - String code = DownstreamEntitlementError.extractCode(e); - if (code != null) { - log.info("Policy run {} blocked by downstream entitlement gate ({})", runId, code); - String message = "Usage limit reached"; - run.failWithCode(message, code, DownstreamEntitlementError.extractSubscribed(e)); - taskManager.setError(runId, message); - } else { - String message = "Policy run failed: " + e.getMessage(); - log.error("Policy run {} failed (downstream HTTP error)", runId, e); + // One policy run = one automation run. Scope the run id on this worker thread (the async + // hop already happened) so every tool sub-step dispatched via InternalApiClient groups into + // a single charge, and two separate policy runs on the same document stay distinct charges. + try (AutomationRunContext.Scope runScope = AutomationRunContext.open(runId)) { + try { + run.markRunning(); + PolicyExecutionResult result = + stepExecutor.execute(run.getDefinition(), inputs, listener); + OutputSpec output = run.getDefinition().output(); + List outputs = + sinkFor(output) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + output); + taskManager.setMultipleFileResults(runId, outputs); + taskManager.setComplete(runId); + run.complete(outputs); + } catch (PolicyInputRequiredException e) { + // Expected path: suspend rather than fail. Persist intermediates as fileIds so the + // run + // can resume after this worker thread is gone. + WaitState wait = suspend(e); + run.waitForInput(wait); + taskManager.addNote(runId, "Waiting for input: " + e.getMessage()); + } catch (InternalApiTimeoutException e) { + String message = toolTimeoutMessage(e); + log.error( + "Policy run {} timed out on {}: {}", + runId, + e.getEndpointPath(), + e.getMessage()); run.fail(message); taskManager.setError(runId, message); + } catch (RestClientResponseException e) { + // A downstream tool call returned an error status. When it's a structured + // entitlement + // response (401/402 with a JSON `error` sentinel), surface that code onto the run + // so + // the + // client can react — e.g. pop the usage-limit modal — instead of only seeing a + // generic + // failure. We don't interpret the code here (that would couple this module to the + // saas + // billing layer); we just pass it through for the client to map. Other statuses + // fall + // through to the generic failure below. + String code = DownstreamEntitlementError.extractCode(e); + if (code != null) { + log.info( + "Policy run {} blocked by downstream entitlement gate ({})", + runId, + code); + String message = "Usage limit reached"; + run.failWithCode( + message, code, DownstreamEntitlementError.extractSubscribed(e)); + taskManager.setError(runId, message); + } else { + String message = "Policy run failed: " + e.getMessage(); + log.error("Policy run {} failed (downstream HTTP error)", runId, e); + run.fail(message); + taskManager.setError(runId, message); + } + } catch (Exception e) { + String message = "Policy run failed: " + e.getMessage(); + log.error("Policy run {} failed", runId, e); + run.fail(message); + taskManager.setError(runId, message); + } finally { + // Always resolve so stream/await callers unblock. + completion.complete(run); } - } catch (Exception e) { - String message = "Policy run failed: " + e.getMessage(); - log.error("Policy run {} failed", runId, e); - run.fail(message); - taskManager.setError(runId, message); - } finally { - // Always resolve so stream/await callers unblock. - completion.complete(run); } } @@ -361,18 +379,26 @@ public class PolicyEngine { * dispatch attributes (and charges) usage to that user. A null/blank principal runs as-is. * Restores the previous MDC value afterward (defensive — worker threads aren't pooled). */ - private static void runAsPrincipal(String billingPrincipal, String fileOwner, Runnable body) { + private static void runAsPrincipal( + String billingPrincipal, String fileOwner, String policyName, Runnable body) { // Billing identity (MDC auditPrincipal) and output-file ownership (JobContext owner) are // set // independently: usage is charged to billingPrincipal, but stored output files are owned by // fileOwner — the user who triggered an org-wide policy — so they can fetch their results. // Either may be null (e.g. login disabled, or a trigger-fired run); each is applied only - // when present and restored afterward (defensive — worker threads aren't pooled). + // when present and restored afterward (defensive — worker threads aren't pooled). The + // policy + // name rides MDC too so each tool step's loopback dispatch (InternalApiClient) can forward + // it as a header, letting the audit tie the step back to its policy. String previousPrincipal = MDC.get(AUDIT_PRINCIPAL_MDC_KEY); + String previousPolicyName = MDC.get(InternalApiClient.POLICY_NAME_MDC_KEY); String previousOwner = JobContext.getOwner(); if (billingPrincipal != null && !billingPrincipal.isBlank()) { MDC.put(AUDIT_PRINCIPAL_MDC_KEY, billingPrincipal); } + if (policyName != null && !policyName.isBlank()) { + MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, policyName); + } if (fileOwner != null && !fileOwner.isBlank()) { JobContext.setOwner(fileOwner); } @@ -384,6 +410,11 @@ public class PolicyEngine { } else { MDC.remove(AUDIT_PRINCIPAL_MDC_KEY); } + if (previousPolicyName != null) { + MDC.put(InternalApiClient.POLICY_NAME_MDC_KEY, previousPolicyName); + } else { + MDC.remove(InternalApiClient.POLICY_NAME_MDC_KEY); + } JobContext.setOwner(previousOwner); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index 5a35b879c0..8575e95e61 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -21,6 +21,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.EditorSource; import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; @@ -44,7 +45,7 @@ public class PolicyRunner { private final ProcessedLedger processedLedger; /** Full-listing sweep: resolve every source, then reconcile the ledger. */ - public List run(Policy policy) { + public SweepOutcome run(Policy policy) { return run(policy, SweepKind.FULL); } @@ -52,10 +53,10 @@ public class PolicyRunner { * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so * one failure does not affect the others. No sources means one run with no input (generator * pipeline). Missing or disabled sources are skipped so one broken reference does not stop the - * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a - * manual trigger can report back which runs to follow. + * rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger + * can report which runs to follow or why nothing ran. */ - public List run(Policy policy, SweepKind sweep) { + public SweepOutcome run(Policy policy, SweepKind sweep) { long sweepStart = System.currentTimeMillis(); PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger); List runIds = new ArrayList<>(); @@ -95,13 +96,19 @@ public class PolicyRunner { policy.id()); } } - return runIds; + return context.outcome(runIds); } - /** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */ + /** + * Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources. + * The supplied documents are still counted against the virtual {@link EditorSource}, scoped to + * the policy's team, so the Sources overview reports the whole team's editor throughput. + */ public PolicyRunHandle runWith( Policy policy, PolicyInputs inputs, PolicyProgressListener listener) { - return policyEngine.runPolicy(policy, inputs, listener); + PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener); + docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size()); + return handle; } /** Run an ad-hoc pipeline with no stored policy (AI/Automate one-offs). */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java index 5302bacdb3..469b5f769e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java @@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext { synchronized Set presentIdentities() { return Set.copyOf(present); } + + /** + * Summarise the sweep from state already in hand (no extra ledger reads): the prefetched rows + * were loaded before claiming, and successful claims flipped their entries to PROCESSING, so + * what remains DONE or ERROR is exactly what this sweep skipped. + */ + synchronized SweepOutcome outcome(List runIds) { + int alreadyProcessed = 0; + int parked = 0; + int processing = 0; + for (String identity : present) { + ClaimState state = prefetched.get(identity); + if (state == null) { + continue; + } + switch (state.status()) { + case DONE -> alreadyProcessed++; + case ERROR -> parked++; + case PROCESSING, INTERRUPTED -> processing++; + } + } + return new SweepOutcome( + runIds, + present.size(), + alreadyProcessed, + parked, + Math.max(0, processing - runIds.size())); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java new file mode 100644 index 0000000000..752299976d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepOutcome.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.policy.engine; + +import java.util.List; + +/** + * What one policy sweep found and started, so a manual trigger can explain an empty result instead + * of a blanket "nothing to do": how many files the sources listed, how many were skipped because + * they are already processed at their current version, how many are parked by a failed run (not + * retried until they change or history is cleared), and how many are still in flight from an + * earlier sweep. Counts are zero for {@link SweepKind#LIGHT} sweeps, which do not take a full + * listing. + */ +public record SweepOutcome( + List runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) { + + public SweepOutcome { + runIds = runIds == null ? List.of() : List.copyOf(runIds); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java new file mode 100644 index 0000000000..99e189f326 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -0,0 +1,286 @@ +package stirling.software.proprietary.policy.input; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.core.io.AbstractResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.PolicyInputs; +import stirling.software.proprietary.policy.s3.S3Config; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3Identities; + +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.S3Object; + +/** + * Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit + * of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and + * version gate come from {@link S3Identities}, so the steady-state sweep never downloads content. + * Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only + * keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style + * addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are + * never signed with the server's own AWS identity), and "mode" which is "consume" (default: a + * processed object is deleted once every policy that claimed it has settled successfully and it is + * still the version that ran; failures stay in place and are not retried until they change) or + * "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and + * keys with a dot-prefixed path segment are never picked up, mirroring the folder source's + * hidden-file rule. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3InputSource implements InputSource { + + private static final String TYPE = "s3"; + + private final S3ConnectionPool connectionPool; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean supports(InputSpec spec) { + return spec != null && TYPE.equals(spec.type()); + } + + /** + * Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or + * a bucket the supplied credentials cannot list. + */ + @Override + public void validate(InputSpec spec) { + S3Config config = S3Config.from(spec.options()); + try { + connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build()); + } catch (SdkException e) { + throw new IllegalArgumentException( + "cannot access s3://" + + config.bucket() + + "/" + + config.prefix() + + ": " + + e.getMessage(), + e); + } + } + + @Override + public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { + S3Config config = S3Config.from(spec.options()); + S3Client client = connectionPool.clientFor(config); + // A listing failure propagates so the sweep reads it as "could not list" (which vetoes + // presence cleanup), never as "verifiably no objects". + List objects = listObjects(client, config); + + if (config.snapshot()) { + return objects.stream() + .map( + object -> + ResolvedInput.of( + PolicyInputs.of( + List.of( + objectResource( + client, config, object))))) + .toList(); + } + + ctx.reportPresent( + objects.stream() + .map(object -> S3Identities.identity(config.bucket(), object.key())) + .toList()); + + List work = new ArrayList<>(); + for (S3Object object : objects) { + String identity = S3Identities.identity(config.bucket(), object.key()); + String gate = S3Identities.gate(object.eTag(), object.size(), object.lastModified()); + if (!ctx.claim(identity, gate, null)) { + continue; + } + work.add( + new ResolvedInput( + PolicyInputs.of(List.of(objectResource(client, config, object))), + success -> + completeConsumed( + ctx, + client, + config, + object.key(), + identity, + gate, + success))); + } + return work; + } + + /** + * Settle at the version this run claimed, then remove the object only when it still carries + * that version and every policy that claimed it has settled DONE, mirroring the folder source's + * consensus delete. A failed run settles ERROR and never deletes; the DONE row of an object + * that could not be deleted still stops reprocessing. + */ + private void completeConsumed( + ResolveContext ctx, + S3Client client, + S3Config config, + String key, + String identity, + String claimGate, + boolean success) { + ctx.settle(identity, claimGate, null, success); + if (!success) { + return; + } + try { + HeadObjectResponse head = + client.headObject( + HeadObjectRequest.builder().bucket(config.bucket()).key(key).build()); + String currentGate = + S3Identities.gate(head.eTag(), head.contentLength(), head.lastModified()); + if (currentGate.equals(claimGate) && ctx.allSettledDone(identity)) { + client.deleteObject( + DeleteObjectRequest.builder().bucket(config.bucket()).key(key).build()); + } + } catch (NoSuchKeyException alreadyGone) { + // Removed by the user or a co-watching policy's own consensus delete: nothing to do. + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return; + } + log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage()); + } catch (SdkException e) { + log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage()); + } + } + + /** Every ingestible object under the configured prefix, across all listing pages. */ + private static List listObjects(S3Client client, S3Config config) { + List objects = new ArrayList<>(); + String continuationToken = null; + do { + ListObjectsV2Request.Builder request = listRequest(config); + if (continuationToken != null) { + request.continuationToken(continuationToken); + } + ListObjectsV2Response page = client.listObjectsV2(request.build()); + for (S3Object object : page.contents()) { + if (ingestible(object)) { + objects.add(object); + } + } + continuationToken = page.nextContinuationToken(); + } while (continuationToken != null); + return objects; + } + + private static ListObjectsV2Request.Builder listRequest(S3Config config) { + ListObjectsV2Request.Builder request = + ListObjectsV2Request.builder().bucket(config.bucket()); + if (!config.prefix().isEmpty()) { + request.prefix(config.prefix()); + } + return request; + } + + /** + * Folder-placeholder keys (ending "/") and keys with a dot-prefixed segment are skipped, so a + * hidden convention (e.g. a future output sink's staging prefix) is never re-ingested. + */ + private static boolean ingestible(S3Object object) { + String key = object.key(); + if (key.isEmpty() || key.endsWith("/")) { + return false; + } + for (String segment : key.split("/")) { + if (segment.startsWith(".")) { + return false; + } + } + return true; + } + + private static Resource objectResource(S3Client client, S3Config config, S3Object object) { + return new S3ObjectResource(client, config.bucket(), object); + } + + /** + * Streams the object on demand, pinned to the ETag observed at listing time so a run never + * reads a different version than the sweep claimed (a swapped object fails the read with a + * precondition error and the new version is claimed by a later sweep). + */ + private static final class S3ObjectResource extends AbstractResource { + + private final S3Client client; + private final String bucket; + private final String key; + private final String eTag; + private final Long size; + + private S3ObjectResource(S3Client client, String bucket, S3Object object) { + this.client = client; + this.bucket = bucket; + this.key = object.key(); + this.eTag = object.eTag(); + this.size = object.size(); + } + + @Override + public InputStream getInputStream() throws IOException { + GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(bucket).key(key); + if (eTag != null && !eTag.isBlank()) { + request.ifMatch(eTag); + } + try { + return client.getObject(request.build()); + } catch (NoSuchKeyException e) { + throw new FileNotFoundException(getDescription() + " no longer exists"); + } catch (SdkException e) { + throw new IOException( + "Could not read " + getDescription() + ": " + e.getMessage(), e); + } + } + + /** Listed just now; readers get a precise error from {@link #getInputStream} instead. */ + @Override + public boolean exists() { + return true; + } + + @Override + public long contentLength() { + return size == null ? -1 : size; + } + + @Override + public String getFilename() { + return key.substring(key.lastIndexOf('/') + 1); + } + + @Override + public String getDescription() { + return "S3 object " + S3Identities.identity(bucket, key); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index ed3cf707ad..52da21208a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.UUID; import java.util.stream.Stream; -import org.apache.commons.io.FilenameUtils; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -82,7 +81,7 @@ public class FolderOutputSink implements PolicyOutputSink { List results = new ArrayList<>(); for (int i = 0; i < outputs.size(); i++) { Resource resource = outputs.get(i); - String name = safeName(resource.getFilename(), i); + String name = OutputNames.safeName(resource.getFilename(), i); Path staged = tmpDir.resolve(UUID.randomUUID().toString()); String contentHash = stage(resource, staged, delivery.policyId() != null); long size = Files.size(staged); @@ -198,29 +197,14 @@ public class FolderOutputSink implements PolicyOutputSink { return Path.of(directory.toString()); } - // Strip any directory component / "../" so a crafted output name cannot escape targetDir. - private static String safeName(String filename, int index) { - if (filename == null || filename.isBlank()) { - return "output-" + index; - } - String name = FilenameUtils.getName(filename); - if (name.isBlank() || ".".equals(name) || "..".equals(name)) { - return "output-" + index; - } - return name; - } - // Non-colliding path, appending " (n)" before the extension. private static Path uniqueTarget(Path dir, String filename) { Path candidate = dir.resolve(filename); if (!Files.exists(candidate)) { return candidate; } - String base = FilenameUtils.getBaseName(filename); - String ext = FilenameUtils.getExtension(filename); - String suffix = ext.isEmpty() ? "" : "." + ext; for (int n = 1; ; n++) { - Path next = dir.resolve(base + " (" + n + ")" + suffix); + Path next = dir.resolve(OutputNames.numbered(filename, n)); if (!Files.exists(next)) { return next; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java new file mode 100644 index 0000000000..e82cc66b28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputNames.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.policy.output; + +import org.apache.commons.io.FilenameUtils; + +/** Output file naming shared by the sinks: sanitised base names and collision suffixes. */ +final class OutputNames { + + private OutputNames() {} + + /** Strip any directory component / "../" so a crafted output name cannot escape the target. */ + static String safeName(String filename, int index) { + if (filename == null || filename.isBlank()) { + return "output-" + index; + } + String name = FilenameUtils.getName(filename); + if (name.isBlank() || ".".equals(name) || "..".equals(name)) { + return "output-" + index; + } + return name; + } + + /** The nth alternative for a taken name, appending " (n)" before the extension. */ + static String numbered(String filename, int n) { + String base = FilenameUtils.getBaseName(filename); + String ext = FilenameUtils.getExtension(filename); + String suffix = ext.isEmpty() ? "" : "." + ext; + return base + " (" + n + ")" + suffix; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java new file mode 100644 index 0000000000..c7d740868a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -0,0 +1,270 @@ +package stirling.software.proprietary.policy.output; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.s3.S3Config; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; +import stirling.software.proprietary.policy.s3.S3Identities; + +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Uploads a run's outputs to the bucket and key prefix given in the {@link OutputSpec} (same + * connection options as the S3 input source; "prefix" is the destination folder). The + * record-before-visible obligation is met without a rename step: a single-part PUT's ETag is the + * MD5 of its content on plain and SSE-S3 buckets, so the ledger row is recorded at that predicted + * gate BEFORE the upload, and the object is claimed under exactly the gate the next listing + * returns. Stores where the returned ETag differs (e.g. SSE-KMS) are re-recorded at the actual gate + * immediately after the PUT - a narrow race those buckets accept rather than a broken loop. Names + * never overwrite: uploads are conditional on the key not existing ({@code If-None-Match: *}), + * re-picking "name (n).ext" on collision exactly like the folder sink; stores without + * conditional-write support fall back to an existence check per candidate. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3OutputSink implements PolicyOutputSink { + + private static final String TYPE = "s3"; + + private final S3ConnectionPool connectionPool; + private final ProcessedLedger processedLedger; + + @Override + public String type() { + return TYPE; + } + + @Override + public boolean supports(OutputSpec spec) { + return spec != null && TYPE.equals(spec.type()); + } + + /** + * Config shape and endpoint guard only - no network probe, since write-only credentials + * (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a + * listing probe would wrongly reject them. + */ + @Override + public void validate(OutputSpec spec) { + connectionPool.clientFor(S3Config.from(spec.options())); + } + + @Override + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { + S3Config config = S3Config.from(spec.options()); + S3Client client = connectionPool.clientFor(config); + + List results = new ArrayList<>(); + for (int i = 0; i < outputs.size(); i++) { + Resource resource = outputs.get(i); + String name = OutputNames.safeName(resource.getFilename(), i); + Path staged = Files.createTempFile("s3-output-", ".tmp"); + try { + String predictedGate = stage(resource, staged, delivery.policyId() != null); + long size = Files.size(staged); + String key = upload(delivery, client, config, name, staged, predictedGate); + String contentType = + MediaTypeFactory.getMediaType(name) + .orElse(MediaType.APPLICATION_OCTET_STREAM) + .toString(); + results.add( + ResultFile.builder() + .fileId(UUID.randomUUID().toString()) + .fileName(S3Identities.identity(config.bucket(), key)) + .contentType(contentType) + .fileSize(size) + .build()); + log.debug( + "Wrote policy run {} output to {}", + delivery.runId(), + S3Identities.identity(config.bucket(), key)); + } finally { + try { + Files.deleteIfExists(staged); + } catch (IOException e) { + log.warn("Could not remove S3 staging file {}: {}", staged, e.getMessage()); + } + } + } + return results; + } + + /** + * Spool the output to a local staging file (S3 needs a known content length, and the body must + * be re-readable across collision retries). For a recorded delivery the MD5 - the predicted + * single-part ETag - is digested in the same pass; ad-hoc runs record nothing and skip it. + */ + private static String stage(Resource resource, Path staged, boolean recorded) + throws IOException { + if (!recorded) { + try (InputStream is = resource.getInputStream(); + OutputStream out = Files.newOutputStream(staged)) { + is.transferTo(out); + } + return null; + } + MessageDigest digest = newMd5(); + try (InputStream is = resource.getInputStream(); + DigestOutputStream out = + new DigestOutputStream(Files.newOutputStream(staged), digest)) { + is.transferTo(out); + } + return HexFormat.of().formatHex(digest.digest()); + } + + /** + * The S3 shape of the folder sink's record-then-rename loop. The ledger row must exist before + * the object is visible, so it is recorded at the predicted gate before the PUT; losing the + * chosen key to a concurrent writer (the conditional PUT fails) forgets the just-recorded row - + * whatever object actually owns that key must stay claimable at any version - then re-picks. A + * PUT that never made the object visible also forgets its row. + */ + private String upload( + OutputDelivery delivery, + S3Client client, + S3Config config, + String name, + Path staged, + String predictedGate) + throws IOException { + String keyPrefix = keyPrefix(config); + boolean conditionalPuts = true; + for (int attempt = 0; ; attempt++) { + String key = keyPrefix + (attempt == 0 ? name : OutputNames.numbered(name, attempt)); + String identity = S3Identities.identity(config.bucket(), key); + if (!conditionalPuts && exists(client, config.bucket(), key)) { + continue; + } + if (delivery.policyId() != null) { + processedLedger.recordOutput(delivery.policyId(), identity, predictedGate, null); + } + PutObjectRequest.Builder put = + PutObjectRequest.builder().bucket(config.bucket()).key(key); + if (conditionalPuts) { + put.ifNoneMatch("*"); + } + try { + PutObjectResponse response = + client.putObject(put.build(), RequestBody.fromFile(staged)); + reRecordIfGateDiffers(delivery, identity, predictedGate, response); + return key; + } catch (S3Exception e) { + forgetRecorded(delivery, identity, predictedGate); + if (conditionalPuts && e.statusCode() == 412) { + // Known edge: if our own PUT succeeded server-side but the response was lost + // and the SDK retried, that retry 412s here too - we then upload under the + // next name, leaving the first object row-less (claimable, single duplicate). + // Requires a response-lost network flake at exactly this moment; accepted. + log.debug("Output key {} taken concurrently; re-picking", identity); + continue; + } + if (conditionalPuts && e.statusCode() == 501) { + // Store without conditional-write support: retry this candidate with a plain + // existence check instead. + log.debug( + "Conditional PUT unsupported by {}; falling back to existence checks", + config.bucket()); + conditionalPuts = false; + attempt--; + continue; + } + throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e); + } catch (SdkException e) { + forgetRecorded(delivery, identity, predictedGate); + throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e); + } + } + } + + /** + * On buckets where a PUT's ETag is not the content MD5 (e.g. SSE-KMS), re-record at the gate + * listings will actually return. The row is briefly at the wrong gate while the object is + * already visible - the narrow race such stores trade for a working self-output skip. + */ + private void reRecordIfGateDiffers( + OutputDelivery delivery, + String identity, + String predictedGate, + PutObjectResponse response) { + if (delivery.policyId() == null) { + return; + } + String actualGate = S3Identities.gate(response.eTag(), null, null); + if (!actualGate.equals(predictedGate)) { + log.debug( + "PUT ETag for {} differs from content MD5 (encrypted bucket?); re-recording", + identity); + processedLedger.recordOutput(delivery.policyId(), identity, actualGate, null); + } + } + + private void forgetRecorded(OutputDelivery delivery, String identity, String predictedGate) { + if (delivery.policyId() != null) { + processedLedger.forgetOutput(delivery.policyId(), identity, predictedGate); + } + } + + private static boolean exists(S3Client client, String bucket, String key) { + try { + client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } catch (S3Exception e) { + if (e.statusCode() == 404) { + return false; + } + throw e; + } + } + + /** The configured prefix as a key-path prefix: "processed" and "processed/" mean the same. */ + private static String keyPrefix(S3Config config) { + String prefix = config.prefix(); + if (prefix.isEmpty() || prefix.endsWith("/")) { + return prefix; + } + return prefix + "/"; + } + + private static MessageDigest newMd5() { + try { + return MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 unavailable", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java new file mode 100644 index 0000000000..152a6de4cf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -0,0 +1,103 @@ +package stirling.software.proprietary.policy.s3; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +/** + * Connection settings shared by the S3 input source and output sink, parsed from a spec's options + * map. Credentials are required: there is deliberately no fallback to the server's own AWS + * credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot} + * is input-only and ignored by the sink. + */ +public record S3Config( + String bucket, + String region, + String prefix, + String endpoint, + String accessKeyId, + String secretAccessKey, + boolean snapshot) { + + private static final String BUCKET_OPTION = "bucket"; + private static final String REGION_OPTION = "region"; + private static final String PREFIX_OPTION = "prefix"; + private static final String ENDPOINT_OPTION = "endpoint"; + private static final String ACCESS_KEY_ID_OPTION = "accessKeyId"; + private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey"; + private static final String MODE_OPTION = "mode"; + private static final String MODE_CONSUME = "consume"; + private static final String MODE_SNAPSHOT = "snapshot"; + + public static S3Config from(Map options) { + String bucket = trimmed(options.get(BUCKET_OPTION)); + if (bucket == null) { + throw new IllegalArgumentException("s3 config requires a 'bucket' option"); + } + String region = trimmed(options.get(REGION_OPTION)); + String prefix = trimmed(options.get(PREFIX_OPTION)); + if (prefix != null && prefix.startsWith("/")) { + prefix = prefix.substring(1); + } + String endpoint = validEndpoint(trimmed(options.get(ENDPOINT_OPTION))); + String accessKeyId = trimmed(options.get(ACCESS_KEY_ID_OPTION)); + String secretAccessKey = trimmed(options.get(SECRET_ACCESS_KEY_OPTION)); + if (accessKeyId == null || secretAccessKey == null) { + throw new IllegalArgumentException( + "s3 config requires an 'accessKeyId' and 'secretAccessKey'"); + } + String mode = trimmed(options.get(MODE_OPTION)); + if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) { + throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'"); + } + return new S3Config( + bucket, + region == null ? "us-east-1" : region, + prefix == null ? "" : prefix, + endpoint, + accessKeyId, + secretAccessKey, + MODE_SNAPSHOT.equals(mode)); + } + + private static String validEndpoint(String endpoint) { + if (endpoint == null) { + return null; + } + URI uri; + try { + uri = new URI(endpoint); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("s3 config 'endpoint' is not a valid URL", e); + } + if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) { + throw new IllegalArgumentException( + "s3 config 'endpoint' must be an http(s) URL, e.g. https://s3.example.com"); + } + return endpoint; + } + + private static String trimmed(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + + /** Never prints the credentials, so an accidental log line cannot leak them. */ + @Override + public String toString() { + return "S3Config[bucket=" + + bucket + + ", region=" + + region + + ", prefix=" + + prefix + + ", endpoint=" + + endpoint + + ", snapshot=" + + snapshot + + "]"; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java new file mode 100644 index 0000000000..4118557dbd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionPool.java @@ -0,0 +1,110 @@ +package stirling.software.proprietary.policy.s3; + +import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PreDestroy; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; + +/** + * Long-lived {@link S3Client}s for policy S3 sources and sinks, one per distinct {@link S3Config}, + * closed at shutdown. An edited spec simply maps to a new entry, and a stale entry costs nothing + * (the URL-connection HTTP client holds no pooled sockets or threads). Clients sign exclusively + * with the spec's own credentials - there is deliberately no fallback to the server's AWS + * credential chain, so user-supplied config can never borrow the host's identity. Endpoints are + * guarded against private addresses before a client is ever built, since they come from portal + * users rather than the operator. + */ +@Service +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3ConnectionPool { + + private final ApplicationProperties applicationProperties; + private final Function clientFactory; + private final Map clients = new ConcurrentHashMap<>(); + + @Autowired + public S3ConnectionPool(ApplicationProperties applicationProperties) { + this(applicationProperties, S3ConnectionPool::buildClient); + } + + /** Factory-injecting constructor for tests. */ + public S3ConnectionPool( + ApplicationProperties applicationProperties, + Function clientFactory) { + this.applicationProperties = applicationProperties; + this.clientFactory = clientFactory; + } + + public S3Client clientFor(S3Config config) { + return clients.computeIfAbsent( + config, + c -> { + requirePermittedEndpoint(c); + return clientFactory.apply(c); + }); + } + + /** + * A user-supplied endpoint must not reach loopback, link-local, or private addresses unless the + * operator has opted in via {@code policies.allowPrivateS3Endpoints}. + */ + private void requirePermittedEndpoint(S3Config config) { + if (config.endpoint() == null) { + return; + } + try { + S3Clients.validateEndpointHost( + URI.create(config.endpoint()), + applicationProperties.getPolicies().isAllowPrivateS3Endpoints(), + "S3 source endpoint", + "set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local" + + " MinIO)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } + + private static S3Client buildClient(S3Config config) { + S3ClientBuilder builder = + S3Client.builder() + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.of(config.region())) + // Path-style addressing whenever a custom endpoint is set: S3-compatible + // stores rarely support virtual-hosted bucket DNS. + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(config.endpoint() != null) + .build()) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create( + config.accessKeyId(), config.secretAccessKey()))); + if (config.endpoint() != null) { + builder.endpointOverride(URI.create(config.endpoint())); + } + return builder.build(); + } + + @PreDestroy + void closeClients() { + clients.values().forEach(S3Client::close); + clients.clear(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java new file mode 100644 index 0000000000..ae2d279b9f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Identities.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.policy.s3; + +import java.time.Instant; + +/** + * The S3 backend's identity and version scheme, shared by {@code S3InputSource} and {@code + * S3OutputSink} so outputs are recorded under exactly the identity and gate the next listing + * derives. Identity is {@code s3://bucket/key}; the gate is the ETag every listing returns for free + * (multipart ETags are not content hashes, so any ETag change simply reads as a new version). + */ +public final class S3Identities { + + private S3Identities() {} + + public static String identity(String bucket, String key) { + return "s3://" + bucket + "/" + key; + } + + /** ETag stripped of its quotes; falls back to size:lastModified for stores that omit it. */ + public static String gate(String eTag, Long size, Instant lastModified) { + if (eTag != null && !eTag.isBlank()) { + return eTag.replace("\"", ""); + } + return (size == null ? -1 : size) + + ":" + + (lastModified == null ? 0 : lastModified.toEpochMilli()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/EditorSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/EditorSource.java new file mode 100644 index 0000000000..3a7a73df5b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/EditorSource.java @@ -0,0 +1,27 @@ +package stirling.software.proprietary.policy.source; + +/** + * The Editor as a virtual, always-present source. Unlike a persisted {@link Source} it is neither + * stored nor configurable: it stands for the documents a team processes by running policies from + * the in-app editor (the {@code POST /api/v1/policies/{id}/run} path). Its throughput is tracked + * through {@link SourceDocCounter} under a synthetic, team-scoped key, so each team sees only its + * own editor activity and the client is only ever handed the opaque {@link #ID}, never a team. + */ +public final class EditorSource { + + /** The single, stable id and type the client sees for the editor row. */ + public static final String ID = "editor"; + + public static final String TYPE = "editor"; + + private EditorSource() {} + + /** + * The per-team {@link SourceDocCounter} key. A {@code null} team (login disabled / self-hosted + * single user) shares one global bucket; otherwise counts are partitioned by team so a team's + * total aggregates every member's editor runs and no other team's. + */ + public static String counterKey(Long teamId) { + return teamId == null ? ID : ID + ":" + teamId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java index 1db5663fd2..f5a14282c9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceAccessGuard.java @@ -34,6 +34,13 @@ public class SourceAccessGuard { /** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */ public Long teamForNewSource() { + return currentTeamId(); + } + + /** + * The current user's team (what scopes their sources), or {@code null} when login is disabled. + */ + public Long currentTeamId() { return enforced() ? policyManagementAuthority.currentUserTeamId() : null; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java index 27d60703de..524b8c369c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceController.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.source; import java.util.List; +import java.util.Map; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.http.HttpStatus; @@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.PolicyStore; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.util.SecretMasker; /** * CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A @@ -65,11 +67,16 @@ public class SourceController { } @GetMapping("/{sourceId}") - @Operation(summary = "Get a source by id") + @Operation( + summary = "Get a source by id", + description = + "Secret-bearing options are returned as a redaction sentinel, never their" + + " stored values; an edit that sends the sentinel back keeps them.") public ResponseEntity get(@PathVariable String sourceId) { return sourceStore .get(sourceId) .filter(sourceAccessGuard::canAccess) + .map(SourceController::withMaskedSecrets) .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -81,6 +88,10 @@ public class SourceController { "The trailing 30-day per-day document series (oldest first) for the source's" + " sparkline.") public ResponseEntity> documentCounts(@PathVariable String sourceId) { + // The editor is virtual: its series is tracked per team, not against a persisted source. + if (EditorSource.ID.equals(sourceId)) { + return ResponseEntity.ok(overviewService.editorDailySeries()); + } return sourceStore .get(sourceId) .filter(sourceAccessGuard::canAccess) @@ -97,7 +108,8 @@ public class SourceController { + " matching source type.") public ResponseEntity save(@RequestBody Source source) { requireSourceEditingAllowed(); - Source owned = resolveOwnership(source); + requireNotEditor(source.id(), source.type()); + Source owned = withStoredSecrets(resolveOwnership(source)); try { validateConfig(owned); } catch (IllegalArgumentException e) { @@ -107,7 +119,7 @@ public class SourceController { // An edited folder source can change which directory needs watching, so re-sync trigger // registrations now instead of waiting for the next reconcile. policyTriggerManager.notifyPoliciesChanged(); - return ResponseEntity.ok(saved); + return ResponseEntity.ok(withMaskedSecrets(saved)); } @DeleteMapping("/{sourceId}") @@ -118,6 +130,7 @@ public class SourceController { + " so the connection can't be pulled out from under a live policy.") public ResponseEntity delete(@PathVariable String sourceId) { requireSourceEditingAllowed(); + requireNotEditor(sourceId, null); Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null); if (source == null) { return ResponseEntity.notFound().build(); @@ -169,6 +182,42 @@ public class SourceController { teamId); } + private static Source withOptions(Source source, Map options) { + return new Source( + source.id(), + source.name(), + source.type(), + options, + source.enabled(), + source.owner(), + source.teamId()); + } + + /** Secrets never leave the server: reads return the redaction sentinel in their place. */ + private static Source withMaskedSecrets(Source source) { + return withOptions(source, SecretMasker.mask(source.options())); + } + + /** + * An edit that round-trips a masked read sends secrets back as the sentinel; restore them from + * the stored source so saving without re-typing keeps them (validation then runs against the + * real values). + */ + private Source withStoredSecrets(Source incoming) { + if (incoming.id() == null || incoming.id().isBlank()) { + return incoming; + } + return sourceStore + .get(incoming.id()) + .map( + existing -> + withOptions( + incoming, + SecretMasker.restoreRedacted( + incoming.options(), existing.options()))) + .orElse(incoming); + } + /** Validate the config against the bean that handles the source's type, as the engine will. */ private void validateConfig(Source source) { InputSpec spec = source.toInputSpec(); @@ -195,6 +244,18 @@ public class SourceController { } } + /** + * The editor is a built-in, virtual source: it is always present and cannot be created, edited, + * or deleted like a persisted connection. Reject any attempt to touch it by id or type. + */ + private static void requireNotEditor(String id, String type) { + if (EditorSource.ID.equals(id) || EditorSource.TYPE.equals(type)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "The editor is a built-in source and cannot be created, edited, or deleted"); + } + } + /** Names of the caller's visible policies that reference the given source. */ private List referencingPolicyNames(String sourceId) { return policyAccessGuard.visibleFrom(policyStore).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java index 412f862f89..c94e4cb079 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceEntity.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source; import java.io.Serializable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; @@ -11,6 +12,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter; + /** * JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative * on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code @@ -45,6 +48,9 @@ public class SourceEntity implements Serializable { @Column(name = "enabled") private boolean enabled; + // Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret + // access key). Lenient so rows written before encryption shipped still load. + @Convert(converter = LenientEncryptedStringConverter.class) @Column(name = "source_json", columnDefinition = "text") private String sourceJson; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 6e5f92ce44..f202a30e23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.util.SecretMasker; /** * Builds the Sources overview: every persisted source the caller's team owns, shown exactly once, @@ -40,7 +41,7 @@ public class SourceOverviewService { Map docStats = docCounter.statsFor(sources.stream().map(Source::id).toList()); - List views = + List persisted = sources.stream() .map( source -> @@ -55,7 +56,13 @@ public class SourceOverviewService { .thenComparing(SourceView::name)) .toList(); - return new SourcesResponse(buildKpis(views), views); + // The editor is a built-in source: always present and pinned first. The KPI strip counts + // only the connections a team configures, so the editor is left out of the KPIs. + List views = new ArrayList<>(); + views.add(editorView(policies)); + views.addAll(persisted); + + return new SourcesResponse(buildKpis(persisted), views); } /** @@ -66,6 +73,49 @@ public class SourceOverviewService { return docCounter.dailySeriesFor(sourceId); } + /** The 30-day daily editor document series (oldest first) for the caller's team. */ + public List editorDailySeries() { + return docCounter.dailySeriesFor( + EditorSource.counterKey(sourceAccessGuard.currentTeamId())); + } + + /** + * The always-present editor row. It has no stored config; its documents are those the team has + * processed by running policies from the editor, and it is "used by" every policy that targets + * the editor as its source. + */ + private SourceView editorView(List policies) { + String key = EditorSource.counterKey(sourceAccessGuard.currentTeamId()); + DocStats docs = docCounter.statsFor(List.of(key)).getOrDefault(key, DocStats.ZERO); + List refs = + policies.stream() + .filter(SourceOverviewService::runsFromEditor) + .map(policy -> new SourceView.PolicyRef(policy.id(), policy.name())) + .toList(); + return new SourceView( + EditorSource.ID, + "Editor", + EditorSource.TYPE, + "active", + refs.size(), + refs, + List.of(), + docs.total(), + docs.last24h(), + docs.last30d()); + } + + /** + * Whether a policy runs from the editor. Editor membership is carried in the policy's output + * metadata ({@code output.options.sources}) - a client-side list the editor writes when a + * policy targets it - rather than as a persisted {@code sourceId}, because the editor is + * virtual and has no stored source to reference. + */ + private static boolean runsFromEditor(Policy policy) { + Object sources = policy.output().options().get("sources"); + return sources instanceof List list && list.contains(EditorSource.ID); + } + /** Policies referencing each source id, across the caller's visible policies. */ private static Map> referencesBySource(List policies) { Map> bySource = new HashMap<>(); @@ -104,13 +154,18 @@ public class SourceOverviewService { return referenceCount == 0 ? "unused" : "active"; } - /** Generic key/value view of the source's config - works for any source type. */ + /** + * Generic key/value view of the source's config - works for any source type. Secret-bearing + * options (e.g. an S3 secret access key) are redacted, not omitted, so the overview still shows + * that a credential is configured. + */ private static List configRows(Source source) { - return source.options().entrySet().stream() + Map masked = SecretMasker.mask(source.options()); + return source.options().keySet().stream() .map( - entry -> + key -> new SourceView.DetailRow( - humanize(entry.getKey()), String.valueOf(entry.getValue()))) + humanize(key), String.valueOf(masked.get(key)))) .toList(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java index 08e6bd4fbe..e393e47833 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/PolicyEntity.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store; import java.io.Serializable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; @@ -11,6 +12,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter; + /** * JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives * as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies @@ -55,6 +58,9 @@ public class PolicyEntity implements Serializable { @Column(name = "sort_order") private Integer sortOrder; + // Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret + // access key). Lenient so rows written before encryption shipped still load. + @Convert(converter = LenientEncryptedStringConverter.class) @Column(name = "policy_json", columnDefinition = "text") private String policyJson; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java index 3e747d7169..32ba4ec77d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/ScheduleTrigger.java @@ -99,11 +99,17 @@ public class ScheduleTrigger implements PolicyTrigger { // Baseline a newly-seen policy to now so it does not fire immediately. Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now); ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone())); - if (!next.toInstant().isAfter(now)) { - lastFiredByPolicy.put(policy.id(), now); - log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name()); - policyRunner.run(policy); + if (next.toInstant().isAfter(now)) { + continue; } + ZonedDateTime later = config.schedule().nextAfter(next); + while (!later.toInstant().isAfter(now)) { + next = later; + later = config.schedule().nextAfter(later); + } + lastFiredByPolicy.put(policy.id(), next.toInstant()); + log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name()); + policyRunner.run(policy); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java index 7c0081dd7c..e140b8f5c8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/PersistentAuditEventRepository.java @@ -184,6 +184,20 @@ public interface PersistentAuditEventRepository extends JpaRepository findByPrincipalIn( @Param("principals") List principals, Pageable pageable); + // Noise-excluding scans for the portal: keep the page window full of meaningful events so the + // list doesn't shrink as read/polling noise (UI_DATA/HTTP_REQUEST) grows in the recent window. + @Query("SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes") + Page findByTypeNotIn( + @Param("excludedTypes") List excludedTypes, Pageable pageable); + + @Query( + "SELECT e FROM PersistentAuditEvent e WHERE e.type NOT IN :excludedTypes AND e.principal" + + " IN :principals") + Page findByTypeNotInAndPrincipalIn( + @Param("excludedTypes") List excludedTypes, + @Param("principals") List principals, + Pageable pageable); + @Query( "SELECT e FROM PersistentAuditEvent e WHERE e.type IN :types AND e.timestamp BETWEEN :startDate AND :endDate") Page findByTypeInAndTimestampBetween( @@ -276,4 +290,25 @@ public interface PersistentAuditEventRepository extends JpaRepository :since") + long countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + @Param("types") List types, + @Param("source") String source, + @Param("principals") List principals, + @Param("since") Instant since); + + @Query( + "SELECT COUNT(DISTINCT e.principal) FROM PersistentAuditEvent e " + + "WHERE e.source = :source AND e.type <> :excludeType " + + "AND e.principal IN :principals AND e.timestamp > :since") + long countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + @Param("source") String source, + @Param("excludeType") String excludeType, + @Param("principals") List principals, + @Param("since") Instant since); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java index 0e9460eca7..74e68d4602 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.commons.io.FilenameUtils; @@ -27,6 +28,7 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.FileStorage; import stirling.software.common.service.InternalApiTimeoutException; @@ -156,33 +158,41 @@ public class AiWorkflowService { throws IOException { validateRequest(request); - // Key by opaque file id, not filename. Filenames aren't guaranteed unique across an - // upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files - // by id in every response shape that asks Java to look a file up again. - Map filesById = new LinkedHashMap<>(); - List files = new ArrayList<>(); - for (AiWorkflowFileInput fileInput : request.getFileInputs()) { - MultipartFile multipartFile = fileInput.getFileInput(); - AiFile aiFile = - new AiFile( - fileIdStrategy.idFor(multipartFile), - multipartFile.getOriginalFilename()); - filesById.put(aiFile.getId(), multipartFile); - files.add(aiFile); - } + // One AI orchestration = one automation run. Scope a run id (on whichever thread runs + // orchestrate — request thread for sync, stream-executor for streaming) so every tool + // sub-step it dispatches via PolicyExecutor → InternalApiClient groups into one charge. + try (AutomationRunContext.Scope ignored = + AutomationRunContext.open(UUID.randomUUID().toString())) { - WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); - initialRequest.setUserMessage(request.getUserMessage().trim()); - initialRequest.setFiles(files); - initialRequest.setConversationHistory(new ArrayList<>(request.getConversationHistory())); - initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls()); - listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); + // Key by opaque file id, not filename. Filenames aren't guaranteed unique across an + // upload (users can rotate the same 'scan.pdf' twice), and the engine identifies files + // by id in every response shape that asks Java to look a file up again. + Map filesById = new LinkedHashMap<>(); + List files = new ArrayList<>(); + for (AiWorkflowFileInput fileInput : request.getFileInputs()) { + MultipartFile multipartFile = fileInput.getFileInput(); + AiFile aiFile = + new AiFile( + fileIdStrategy.idFor(multipartFile), + multipartFile.getOriginalFilename()); + filesById.put(aiFile.getId(), multipartFile); + files.add(aiFile); + } - WorkflowState state = new WorkflowState.Pending(initialRequest); - while (state instanceof WorkflowState.Pending pending) { - state = advance(pending.request(), filesById, listener); + WorkflowTurnRequest initialRequest = new WorkflowTurnRequest(); + initialRequest.setUserMessage(request.getUserMessage().trim()); + initialRequest.setFiles(files); + initialRequest.setConversationHistory( + new ArrayList<>(request.getConversationHistory())); + initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls()); + listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING)); + + WorkflowState state = new WorkflowState.Pending(initialRequest); + while (state instanceof WorkflowState.Pending pending) { + state = advance(pending.request(), filesById, listener); + } + return ((WorkflowState.Terminal) state).response(); } - return ((WorkflowState.Terminal) state).response(); } private WorkflowState advance( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index a56ece5285..2fee8b5c7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -37,9 +37,11 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.InternalApiClient; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.accountlink.BillableOperationClassifier; +import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -469,6 +471,73 @@ public class AuditService { } } + /** + * Merge controller-supplied context into the audit data, read in the aspect's {@code finally} + * after the controller body has run. A policy run stamps its name and step endpoints as request + * attributes ({@link AuditContext}); the internal loopback dispatch that executes each pipeline + * step carries the automation marker header ({@link InternalApiClient#AUTOMATION_HEADER}). + * Surfacing these lets the portal label a run as its policy instead of the raw {@code /run} + * endpoint, and flag its sub-steps as automation rather than direct user actions. + * + * @param data The existing audit data map + * @param req The current request, or null when not in a web context + */ + public void addAutomationContext(Map data, HttpServletRequest req) { + if (req == null) { + return; + } + Object policyName = req.getAttribute(AuditContext.REQ_ATTR_POLICY_NAME); + if (policyName == null) { + // A pipeline step's loopback dispatch carries its parent policy name as a header, so + // the + // step audit ties back to the policy that ran it (not a bare direct call). + String header = req.getHeader(InternalApiClient.POLICY_NAME_HEADER); + if (header != null && !header.isBlank()) { + policyName = header; + } + } + if (policyName != null) { + // Both sources are caller-controlled (the header is spoofable and, unlike the sender in + // InternalApiClient, uncapped); strip newlines and cap before persisting to audit JSON. + String safe = capLabel(String.valueOf(policyName)); + if (!safe.isEmpty()) { + data.put("policyName", safe); + } + } + Object steps = req.getAttribute(AuditContext.REQ_ATTR_POLICY_STEPS); + if (steps instanceof List list && !list.isEmpty()) { + // Caller-supplied and unbounded; cap count and each entry so a crafted run can't + // bloat the audit JSON the portal cache loads and parses in bulk. + List safeSteps = + list.stream() + .limit(MAX_POLICY_STEPS) + .map(s -> capLabel(String.valueOf(s))) + .filter(s -> !s.isEmpty()) + .toList(); + if (!safeSteps.isEmpty()) { + data.put("policySteps", safeSteps); + } + } + if ("true".equalsIgnoreCase(req.getHeader(InternalApiClient.AUTOMATION_HEADER))) { + data.put("automation", Boolean.TRUE); + } + } + + /** Max characters kept for a persisted policy label; mirrors the InternalApiClient send cap. */ + private static final int MAX_LABEL_LEN = 200; + + /** Max step endpoints kept on a run's audit event; the portal only shows the first few. */ + private static final int MAX_POLICY_STEPS = 50; + + /** Single-line, length-capped label safe to persist to audit JSON and render in the portal. */ + private static String capLabel(String value) { + if (value == null) { + return ""; + } + String safe = value.replaceAll("[\\r\\n]", " ").trim(); + return safe.length() > MAX_LABEL_LEN ? safe.substring(0, MAX_LABEL_LEN) : safe; + } + /** * Add method arguments to the audit data * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java index 1678cd095f..5a94e9d2b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalAuditReadService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.PortalAuditEventRow; import stirling.software.proprietary.model.security.PersistentAuditEvent; import stirling.software.proprietary.repository.PersistentAuditEventRepository; @@ -24,12 +25,21 @@ public class PortalAuditReadService { /** Newest rows to scan; each surface filters this down to what it shows. */ private static final int SCAN_LIMIT = 400; + /** + * Read/polling noise excluded at the query level so the scan window stays full of meaningful + * events. Otherwise a busy scope's recent rows fill with these and the visible list shrinks as + * traffic grows - the "audit getting smaller over time" a user would see. No portal surface + * shows these types anyway (the infra tab and documents feed both drop them). + */ + private static final List NOISE_TYPES = + List.of(AuditEventType.UI_DATA.name(), AuditEventType.HTTP_REQUEST.name()); + private final PersistentAuditEventRepository auditRepository; /** Recent whole-server events (admins). */ @Cacheable(value = CACHE_NAME, key = "'server'") public List serverEvents() { - return toRows(auditRepository.findAll(recentPage()).getContent()); + return toRows(auditRepository.findByTypeNotIn(NOISE_TYPES, recentPage()).getContent()); } /** Recent events by the given principals (team scope). Empty principals yield an empty list. */ @@ -38,7 +48,10 @@ public class PortalAuditReadService { if (principals.isEmpty()) { return List.of(); } - return toRows(auditRepository.findByPrincipalIn(principals, recentPage()).getContent()); + return toRows( + auditRepository + .findByTypeNotInAndPrincipalIn(NOISE_TYPES, principals, recentPage()) + .getContent()); } private static PageRequest recentPage() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java index 8784d5c5b6..fcae36f61b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalDocumentsService.java @@ -61,8 +61,17 @@ public class PortalDocumentsService { continue; } String path = asString(data.get("path")); - String source = sourceLabel(asString(data.get("__origin"))); - String product = "API integration".equals(source) ? "API" : "Editor"; + // Pipeline steps run over an internal loopback (API-key auth), so origin alone reads as + // "API". The automation marker distinguishes a policy-run step from real API traffic. + boolean automation = isAutomation(data); + String policyName = asString(data.get("policyName")); + String source = + automation + ? (policyName != null && !policyName.isBlank() + ? "Policy: " + policyName + : "Policy automation") + : sourceLabel(asString(data.get("__origin"))); + String product = automation ? "Automation" : productLabel(source); String action = prettyTool(path); boolean failed = isFailure(data); Instant ts = event.timestamp(); @@ -174,6 +183,15 @@ public class PortalDocumentsService { return "Web upload"; } + private static String productLabel(String source) { + return "API integration".equals(source) ? "API" : "Editor"; + } + + private static boolean isAutomation(Map data) { + Object v = data.get("automation"); + return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v)); + } + private static String docType(String contentType, String name) { String ct = contentType == null ? "" : contentType.toLowerCase(Locale.ROOT); if (ct.contains("pdf") || name.toLowerCase(Locale.ROOT).endsWith(".pdf")) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index 73d45800a8..c47df3649f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -54,6 +54,7 @@ public class PortalInfraAuditService { .limit(RETURN_LIMIT) .toList(); + int policy = (int) events.stream().filter(e -> "policy".equals(e.getCategory())).count(); int processing = (int) events.stream().filter(e -> "processing".equals(e.getCategory())).count(); int elevation = @@ -63,6 +64,7 @@ public class PortalInfraAuditService { InfraAuditSummary summary = InfraAuditSummary.builder() .totalEvents(events.size()) + .policy(policy) .processing(processing) .elevation(elevation) .config(config) @@ -84,20 +86,32 @@ public class PortalInfraAuditService { private InfraAuditEventDto toDto(PortalAuditEventRow event) { Map data = parseData(event); String path = asString(data.get("path")); - String category = categoryFor(event.type(), path); + String policyName = asString(data.get("policyName")); + boolean automation = isAutomation(data); + // Classify a dispatch by its real run-path URI, not a policyName: the latter can be spoofed + // via the X-Stirling-Policy-Name header to make a direct call pose as a policy row. + boolean policyDispatch = isPolicyRunPath(path) && !automation; + // A dispatch is its own "policy" category so the UI badges it as a policy run, not a + // generic processing op; its internal steps keep their real tool category. + String category = policyDispatch ? "policy" : categoryFor(event.type(), path); return InfraAuditEventDto.builder() .id(String.valueOf(event.id())) .timestamp(event.timestamp() == null ? "" : TS_FORMAT.format(event.timestamp())) .category(category) - .action(actionFor(event.type(), path)) + .action(actionFor(event.type(), path, policyName, automation)) .actor(event.principal()) - .target(targetFor(category, path, data)) + .target(targetFor(category, path, data, policyDispatch)) .status(statusFor(event.type(), category, data)) .latencyMs(asLong(data.get("latencyMs"))) .build(); } + private static boolean isAutomation(Map data) { + Object v = data.get("automation"); + return Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v)); + } + private Map parseData(PortalAuditEventRow event) { if (event.data() == null || event.data().isEmpty()) { return Map.of(); @@ -140,7 +154,37 @@ public class PortalInfraAuditService { || p.contains("redact"); } - private static String actionFor(String type, String path) { + /** + * Label for a row. A genuine policy dispatch (a {@code /policies/.../run} request) shows the + * policy it ran; an internal pipeline step (automation marker) is flagged so it isn't read as a + * direct action. The name is only shown as the action on a real run URI, so a spoofed + * X-Stirling-Policy-Name header on a direct tool call can't overwrite its true action. + */ + private static String actionFor( + String type, String path, String policyName, boolean automation) { + if (!automation && isPolicyRunPath(path)) { + // A run with no name (ad-hoc pipeline) still reads better than the "run" endpoint. + return policyName != null ? policyName : "Policy run"; + } + String base = baseActionFor(type, path); + if (automation) { + return policyName != null + ? base + " (policy: " + policyName + ")" + : base + " (automation)"; + } + return base; + } + + /** + * The pipeline-run endpoints: {@code /policies/run}, {@code /run/stream}, {@code /{id}/run}. + */ + private static boolean isPolicyRunPath(String path) { + return path != null + && path.contains("/policies/") + && (path.endsWith("/run") || path.endsWith("/run/stream")); + } + + private static String baseActionFor(String type, String path) { AuditEventType t = AuditEventType.fromString(type); if (t == null) { return prettyTool(path); @@ -191,7 +235,32 @@ public class PortalInfraAuditService { return sb.isEmpty() ? "PDF operation" : sb.toString(); } - private static String targetFor(String category, String path, Map data) { + /** "Auto Redact, Compress PDF" from the run's step endpoints; first three, then "+N more". */ + private static String prettyStepList(Object steps) { + if (!(steps instanceof List list) || list.isEmpty()) { + return null; + } + int shown = Math.min(3, list.size()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < shown; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(prettyTool(asString(list.get(i)))); + } + if (list.size() > shown) { + sb.append(" +").append(list.size() - shown).append(" more"); + } + return sb.toString(); + } + + private static String targetFor( + String category, String path, Map data, boolean policyDispatch) { + if (policyDispatch) { + // The run touches no single file at this level; show the tools the policy runs instead. + String steps = prettyStepList(data.get("policySteps")); + return steps != null ? steps : "Pipeline"; + } if ("auth".equals(category)) { // Auth events don't act on a resource; the session is the closest thing. return "Web session"; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java index a3975de194..5a9619c2dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/util/SecretMasker.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.util; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; @@ -13,10 +14,15 @@ import stirling.software.common.util.RegexPatternUtils; @Slf4j public final class SecretMasker { + /** The placeholder masked values are replaced with; reads as "a secret is set". */ + public static final String REDACTED = "********"; + private static final Pattern SENSITIVE = RegexPatternUtils.getInstance() .getPattern( - "(?i)\\b(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b"); + // secret[_-]?access[_-]?key precedes plain secret so camelCase keys + // like secretAccessKey (no word boundary after "secret") still match. + "(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b"); private SecretMasker() {} @@ -47,8 +53,28 @@ public final class SecretMasker { private static Object deepMaskValue(String key, Object value) { if (key != null && SENSITIVE.matcher(key).find()) { - return "***REDACTED***"; + return REDACTED; } return deepMask(value); } + + /** + * Restore top-level values the caller sent back as the {@link #REDACTED} sentinel from the + * stored map, so a masked read can round-trip through an edit without re-typing secrets. A + * sentinel with no stored counterpart is left as-is (it fails whatever validates it, rather + * than silently passing an unset secret). + */ + public static Map restoreRedacted( + Map incoming, Map stored) { + if (incoming == null || stored == null) { + return incoming; + } + Map merged = new LinkedHashMap<>(incoming); + merged.replaceAll( + (key, value) -> + REDACTED.equals(value) && stored.containsKey(key) + ? stored.get(key) + : value); + return merged; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java index baa6755493..c63e6b8e47 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; import java.lang.reflect.Field; @@ -258,6 +259,24 @@ class ResourceAccessServiceTest { .isFalse(); } + @Test + void teamLeadDefaultOnTeamResourceDeniesForeignTeamsLead() { + // Team-owned resource: the default admits only the owning team's leads. A lead of + // some other team must be denied even though an unscoped "is any team leader" + // check would admit them (lenient stub: the scoped path must never consult it). + stubGrants(); + User foreignLead = user(6); + lenient().when(teamLeadLookup.isAnyTeamLeader(foreignLead)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, + foreignLead)) + .isFalse(); + } + // ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ---- @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java new file mode 100644 index 0000000000..b61ded9708 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/crypto/LenientEncryptedStringConverterTest.java @@ -0,0 +1,45 @@ +package stirling.software.proprietary.integration.crypto; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class LenientEncryptedStringConverterTest { + + private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter(); + + @BeforeAll + static void initKey() throws Exception { + KeyGenerator generator = KeyGenerator.getInstance("AES"); + generator.init(256); + SecretKey key = generator.generateKey(); + CredentialEncryption.initialiseForTesting(key); + } + + @Test + void roundTripsThroughCiphertext() { + String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}"; + + String stored = converter.convertToDatabaseColumn(json); + + assertThat(stored).isNotEqualTo(json).doesNotContain("shh"); + assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json); + } + + @Test + void legacyPlaintextRowsPassThroughOnRead() { + String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}"; + + assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy); + } + + @Test + void nullsPassThrough() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + assertThat(converter.convertToEntityAttribute(null)).isNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index f38263ad3e..c02945fdad 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -17,6 +18,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -34,16 +36,21 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.engine.SweepOutcome; import stirling.software.proprietary.policy.ledger.ProcessedLedger; +import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunView; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.EditorSource; import stirling.software.proprietary.policy.source.SourceAccessGuard; +import stirling.software.proprietary.policy.source.SourceDocCounter; import stirling.software.proprietary.policy.source.SourceStore; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.util.SecretMasker; @ExtendWith(MockitoExtension.class) @DisplayName("PolicyController") @@ -54,6 +61,7 @@ class PolicyControllerTest { @Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore; @Mock private SourceStore sourceStore; @Mock private SourceAccessGuard sourceAccessGuard; + @Mock private SourceDocCounter docCounter; @Mock private PolicyValidator policyValidator; @Mock private PolicyAccessGuard policyAccessGuard; @Mock private PolicyManagementAuthority policyManagementAuthority; @@ -87,6 +95,7 @@ class PolicyControllerTest { policyStore, sourceStore, sourceAccessGuard, + docCounter, policyValidator, policyAccessGuard, policyManagementAuthority, @@ -128,6 +137,17 @@ class PolicyControllerTest { return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId); } + private static Policy s3OutputPolicy(String id, String secret) { + OutputSpec output = + new OutputSpec( + "s3", + Map.of( + "bucket", "outbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", secret)); + return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L); + } + private static PolicyRunHandle handle(String runId) { PolicyRun run = new PolicyRun(runId, null, definitionWithStep()); return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); @@ -150,6 +170,18 @@ class PolicyControllerTest { assertThat(response.getBody().getJobId()).isEqualTo("run-1"); } + @Test + @DisplayName("feeds the editor source, scoped to the caller's team") + void adHocRunFeedsTheEditorSource() throws Exception { + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + when(sourceAccessGuard.currentTeamId()).thenReturn(3L); + + controller.run(definitionWithStep(), new PolicyRunFiles()); + + verify(docCounter).record(EditorSource.counterKey(3L), 0L); + } + @Test @DisplayName("rejects a pipeline with no steps") void rejectsEmptyPipeline() { @@ -263,6 +295,27 @@ class PolicyControllerTest { verify(policyTriggerManager).notifyPoliciesChanged(); } + @Test + @DisplayName("saving the sentinel back keeps the stored output secret") + void saveRestoresOutputSecrets() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy existing = s3OutputPolicy("p1", "shh"); + when(policyStore.get("p1")).thenReturn(Optional.of(existing)); + when(policyAccessGuard.canAccess(existing)).thenReturn(true); + when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); + + ResponseEntity response = + controller.savePolicy(s3OutputPolicy("p1", SecretMasker.REDACTED)); + + ArgumentCaptor stored = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(stored.capture()); + assertThat(stored.getValue().output().options().get("secretAccessKey")) + .isEqualTo("shh"); + // The save response is masked again; only the store sees the real value. + assertThat(response.getBody().output().options().get("secretAccessKey")) + .isEqualTo(SecretMasker.REDACTED); + } + @Test @DisplayName("forbidden when login enabled and caller cannot edit") void forbidden() { @@ -363,6 +416,20 @@ class PolicyControllerTest { assertThat(response.getBody().id()).isEqualTo("a"); } + @Test + @DisplayName("getPolicy returns output secrets as the redaction sentinel") + void getMasksOutputSecrets() { + Policy p = s3OutputPolicy("a", "shh"); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + + Policy read = controller.getPolicy("a").getBody(); + + assertThat(read.output().options().get("secretAccessKey")) + .isEqualTo(SecretMasker.REDACTED); + assertThat(read.output().options().get("bucket")).isEqualTo("outbox"); + } + @Test @DisplayName("getPolicy returns 404 when not accessible") void getNotAccessible() { @@ -536,17 +603,18 @@ class PolicyControllerTest { } @Test - @DisplayName("trigger runs an accessible policy against its sources and returns run ids") + @DisplayName("trigger runs an accessible policy against its sources and returns the sweep") void triggersRun() { Policy p = policy("a", 1L); when(policyStore.get("a")).thenReturn(Optional.of(p)); when(policyAccessGuard.canAccess(p)).thenReturn(true); - when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b")); + SweepOutcome outcome = new SweepOutcome(List.of("run-a", "run-b"), 3, 1, 0, 0); + when(policyRunner.run(p)).thenReturn(outcome); - ResponseEntity> response = controller.trigger("a"); + ResponseEntity response = controller.trigger("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); - assertThat(response.getBody()).containsExactly("run-a", "run-b"); + assertThat(response.getBody()).isEqualTo(outcome); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index 13189b2481..e21f371305 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.policy.engine; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -26,10 +27,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.ResolveContext; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -39,6 +42,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.PolicyRunStatus; import stirling.software.proprietary.policy.progress.PolicyProgressListener; +import stirling.software.proprietary.policy.source.EditorSource; import stirling.software.proprietary.policy.source.InProcessSourceDocCounter; import stirling.software.proprietary.policy.source.InProcessSourceStore; import stirling.software.proprietary.policy.source.Source; @@ -56,6 +60,7 @@ class PolicyRunnerTest { @Mock private ProcessedLedger processedLedger; private final SourceStore sourceStore = new InProcessSourceStore(); + private final InProcessSourceDocCounter docCounter = new InProcessSourceDocCounter(); private PolicyRunner runner; @BeforeEach @@ -65,7 +70,7 @@ class PolicyRunnerTest { policyEngine, List.of(folderSource), sourceStore, - new InProcessSourceDocCounter(), + docCounter, processedLedger); } @@ -85,6 +90,42 @@ class PolicyRunnerTest { verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); } + @Test + void reportsWhatTheSweepSkippedSoAnEmptyTriggerExplainsItself() throws Exception { + InProcessProcessedLedger ledger = new InProcessProcessedLedger(); + PolicyRunner reporting = + new PolicyRunner( + policyEngine, + List.of(folderSource), + sourceStore, + new InProcessSourceDocCounter(), + ledger); + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + // One file already processed at its current version, one parked by a failed run. + ledger.claim("p1", "/in/done.pdf", "g1", null); + ledger.settle("p1", "/in/done.pdf", "g1", null, true); + ledger.claim("p1", "/in/failed.pdf", "g2", null); + ledger.settle("p1", "/in/failed.pdf", "g2", null, false); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenAnswer( + invocation -> { + ResolveContext ctx = invocation.getArgument(1); + ctx.reportPresent(List.of("/in/done.pdf", "/in/failed.pdf")); + // Both are at their settled versions, so neither claims. + return List.of(); + }); + + SweepOutcome outcome = reporting.run(policy); + + assertTrue(outcome.runIds().isEmpty()); + assertEquals(2, outcome.filesListed()); + assertEquals(1, outcome.alreadyProcessed()); + assertEquals(1, outcome.parked()); + assertEquals(0, outcome.inFlight()); + } + @Test void pullsEverySourceAndRunsOnePerUnitOfWork() throws Exception { InputSpec spec = InputSpec.folder("/in"); @@ -257,6 +298,33 @@ class PolicyRunnerTest { verifyNoInteractions(folderSource); } + @Test + void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() { + Policy policy = + new Policy( + "p1", + "p", + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 7L); + PolicyInputs inputs = + PolicyInputs.of( + List.of( + new ByteArrayResource("a".getBytes()), + new ByteArrayResource("b".getBytes()))); + when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP)) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.runWith(policy, inputs, PolicyProgressListener.NOOP); + + String key = EditorSource.counterKey(7L); + assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total()); + } + /** Persists each spec as a source and returns a policy referencing them by id. */ private Policy policy(List sources) { List sourceIds = diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java new file mode 100644 index 0000000000..4e1e1fc305 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java @@ -0,0 +1,231 @@ +package stirling.software.proprietary.policy.input; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * End-to-end {@link S3InputSource} test against a real S3 API (MinIO), through the production + * client factory: listing, claiming, streaming, consensus delete, and save-time validation. + */ +@Testcontainers(disabledWithoutDocker = true) +class S3InputSourceMinioTest { + + private static final String POLICY = "p1"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + @Container + static MinIOContainer minio = + new MinIOContainer("minio/minio:latest") + .withUserName(ACCESS_KEY) + .withPassword(SECRET_KEY); + + private static S3Client adminClient; + private static int bucketCounter; + + private String bucket; + private S3InputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + if (adminClient == null) { + adminClient = + S3Client.builder() + .endpointOverride(java.net.URI.create(minio.getS3URL())) + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.US_EAST_1) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .serviceConfiguration( + S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + } + bucket = "policy-inbox-" + ++bucketCounter; + adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + + // The MinIO endpoint resolves to loopback, so the operator opt-in must be on. + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(true); + source = new S3InputSource(new S3ConnectionPool(properties)); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + } + + @Test + void consumeListsStreamsAndDeletesByConsensus() throws IOException { + put("incoming/doc.pdf", "pdf bytes"); + put("incoming/other.txt", "text"); + + List work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx); + + assertThat(work).hasSize(2); + assertThat(ctx.present) + .containsExactlyInAnyOrder( + "s3://" + bucket + "/incoming/doc.pdf", + "s3://" + bucket + "/incoming/other.txt"); + assertThat(read(work.get(0))).isIn("pdf bytes", "text"); + // In flight: nothing to claim on a second sweep. + assertThat(source.resolve(spec(Map.of("prefix", "incoming/")), ctx)).isEmpty(); + + work.forEach(unit -> unit.onComplete().accept(true)); + assertThat(exists("incoming/doc.pdf")).isFalse(); + assertThat(exists("incoming/other.txt")).isFalse(); + } + + @Test + void aFailedObjectStaysInTheBucket() throws IOException { + put("doc.pdf", "data"); + + source.resolve(spec(Map.of()), ctx).get(0).onComplete().accept(false); + + assertThat(exists("doc.pdf")).isTrue(); + assertThat(source.resolve(spec(Map.of()), ctx)).isEmpty(); + } + + @Test + void anObjectOverwrittenMidRunSurvivesTheDeleteAndRunsAgain() throws IOException { + put("doc.pdf", "v1"); + + List work = source.resolve(spec(Map.of()), ctx); + put("doc.pdf", "v2 with a different etag"); + work.get(0).onComplete().accept(true); + + assertThat(exists("doc.pdf")).isTrue(); + assertThat(source.resolve(spec(Map.of()), ctx)).hasSize(1); + } + + @Test + void prefixLimitsWhatIsRead() throws IOException { + put("incoming/doc.pdf", "data"); + put("archive/old.pdf", "data"); + + List work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx); + + assertThat(work).hasSize(1); + assertThat(ctx.present).containsExactly("s3://" + bucket + "/incoming/doc.pdf"); + } + + @Test + void validateAcceptsAReachableBucketAndRejectsBadCredentials() { + source.validate(spec(Map.of())); + + Map wrongSecret = new HashMap<>(baseOptions()); + wrongSecret.put("secretAccessKey", "not-the-secret"); + assertThatThrownBy(() -> source.validate(new InputSpec("s3", wrongSecret))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot access"); + + Map missingBucket = new HashMap<>(baseOptions()); + missingBucket.put("bucket", "no-such-bucket-here"); + assertThatThrownBy(() -> source.validate(new InputSpec("s3", missingBucket))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot access"); + } + + @Test + void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() { + S3InputSource guarded = + new S3InputSource(new S3ConnectionPool(new ApplicationProperties())); + + assertThatThrownBy(() -> guarded.validate(spec(Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("policies.allowPrivateS3Endpoints"); + } + + private Map baseOptions() { + return Map.of( + "bucket", bucket, + "endpoint", minio.getS3URL(), + "accessKeyId", ACCESS_KEY, + "secretAccessKey", SECRET_KEY); + } + + private InputSpec spec(Map extra) { + Map options = new HashMap<>(baseOptions()); + options.putAll(extra); + return new InputSpec("s3", options); + } + + private void put(String key, String content) { + adminClient.putObject( + PutObjectRequest.builder().bucket(bucket).key(key).build(), + RequestBody.fromString(content, StandardCharsets.UTF_8)); + } + + private boolean exists(String key) { + try { + adminClient.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + private static String read(ResolvedInput unit) throws IOException { + try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private class RecordingContext implements ResolveContext { + + private final List present = new ArrayList<>(); + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(POLICY, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(POLICY, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java new file mode 100644 index 0000000000..73995248dd --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java @@ -0,0 +1,327 @@ +package stirling.software.proprietary.policy.input; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Object; + +/** + * Tests for {@link S3InputSource}: consume mode tracks objects in place through the ledger and + * removes them by consensus, snapshot stays stateless, and discovery skips folder placeholders and + * dot-prefixed keys. + */ +@ExtendWith(MockitoExtension.class) +class S3InputSourceTest { + + private static final String POLICY = "p1"; + private static final String BUCKET = "inbox-bucket"; + + @Mock private S3Client s3Client; + + private S3InputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; + + @BeforeEach + void setUp() { + source = + new S3InputSource( + new S3ConnectionPool(new ApplicationProperties(), config -> s3Client)); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); + } + + @Test + void consumeRemovesTheObjectOnceProcessed() throws IOException { + listingReturns(object("doc.pdf", "\"etag-1\"")); + headReturns("doc.pdf", "\"etag-1\""); + + List work = source.resolve(spec(), ctx); + + assertEquals(1, work.size()); + assertEquals(1, work.get(0).inputs().primary().size()); + // In flight: a second sweep does not pick it up again. + assertTrue(source.resolve(spec(), ctx).isEmpty()); + + work.get(0).onComplete().accept(true); + verify(s3Client).deleteObject(any(DeleteObjectRequest.class)); + assertTrue(source.resolve(spec(), ctx).isEmpty()); + } + + @Test + void anObjectReplacedMidRunSurvivesTheDelete() throws IOException { + listingReturns(object("doc.pdf", "\"etag-1\"")); + // The object is overwritten while the run is executing. + headReturns("doc.pdf", "\"etag-2\""); + + List work = source.resolve(spec(), ctx); + work.get(0).onComplete().accept(true); + + // The delete is version-guarded: the replacement is not the object that ran, so it stays + // and is claimed as fresh work instead of being marked processed. + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + listingReturns(object("doc.pdf", "\"etag-2\"")); + assertEquals(1, source.resolve(spec(), ctx).size()); + } + + @Test + void aSharedObjectIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException { + listingReturns(object("doc.pdf", "\"etag-1\"")); + headReturns("doc.pdf", "\"etag-1\""); + RecordingContext other = new RecordingContext("p2"); + + List mine = source.resolve(spec(), ctx); + List theirs = source.resolve(spec(), other); + assertEquals(1, mine.size()); + assertEquals(1, theirs.size()); + + mine.get(0).onComplete().accept(true); + // The other policy's claim is still in flight, so the first finisher must not delete. + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + + theirs.get(0).onComplete().accept(true); + verify(s3Client).deleteObject(any(DeleteObjectRequest.class)); + } + + @Test + void aFailedObjectStaysAndIsNotRetriedUntilItChanges() throws IOException { + listingReturns(object("doc.pdf", "\"etag-1\"")); + + source.resolve(spec(), ctx).get(0).onComplete().accept(false); + + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + assertTrue(source.resolve(spec(), ctx).isEmpty()); + + // A new upload carries a new ETag, which reads as a new version and retries. + listingReturns(object("doc.pdf", "\"etag-2\"")); + assertEquals(1, source.resolve(spec(), ctx).size()); + } + + @Test + void snapshotReadsStatelesslyEverySweep() throws IOException { + listingReturns(object("doc.pdf", "\"etag-1\"")); + InputSpec spec = new InputSpec("s3", options(Map.of("mode", "snapshot"))); + + List first = source.resolve(spec, ctx); + first.get(0).onComplete().accept(true); + List second = source.resolve(spec, ctx); + + assertEquals(1, first.size()); + assertEquals(1, second.size()); + verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); + assertTrue(ctx.present.isEmpty()); + } + + @Test + void folderPlaceholdersAndDotPrefixedKeysAreSkipped() throws IOException { + listingReturns( + object("doc.pdf", "\"etag-1\""), + object("incoming/", "\"etag-2\""), + object(".stirling/tmp/staged.pdf", "\"etag-3\""), + object("incoming/.hidden.pdf", "\"etag-4\"")); + + List work = source.resolve(spec(), ctx); + + assertEquals(1, work.size()); + assertEquals(List.of("s3://" + BUCKET + "/doc.pdf"), ctx.present); + } + + @Test + void listingPagesAreAllRead() throws IOException { + ListObjectsV2Response firstPage = + ListObjectsV2Response.builder() + .contents(object("a.pdf", "\"etag-a\"")) + .nextContinuationToken("next") + .build(); + ListObjectsV2Response secondPage = + ListObjectsV2Response.builder().contents(object("b.pdf", "\"etag-b\"")).build(); + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(firstPage, secondPage); + + assertEquals(2, source.resolve(spec(), ctx).size()); + } + + @Test + void aListingFailurePropagatesSoTheSweepVetoesCleanup() { + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenThrow(SdkClientException.create("connection refused")); + + assertThrows(SdkClientException.class, () -> source.resolve(spec(), ctx)); + } + + @Test + void resourceStreamsTheObjectAndNamesItByKeyBasename() throws IOException { + listingReturns(object("incoming/doc.pdf", "\"etag-1\"")); + byte[] payload = "data".getBytes(StandardCharsets.UTF_8); + when(s3Client.getObject(any(GetObjectRequest.class))) + .thenReturn( + new ResponseInputStream<>( + GetObjectResponse.builder().build(), + AbortableInputStream.create(new ByteArrayInputStream(payload)))); + + var resource = source.resolve(spec(), ctx).get(0).inputs().primary().get(0); + + assertEquals("doc.pdf", resource.getFilename()); + // Content length comes from the listing, not a download. + assertEquals(4, resource.contentLength()); + try (var stream = resource.getInputStream()) { + assertEquals("data", new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } + } + + @Test + void aMissingETagFallsBackToSizeAndLastModified() throws IOException { + Instant modified = Instant.parse("2026-01-01T00:00:00Z"); + listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build()); + + assertEquals(1, source.resolve(spec(), ctx).size()); + // The same gate on the next sweep reads as already claimed. + listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build()); + assertTrue(source.resolve(spec(), ctx).isEmpty()); + } + + @Test + void validateRejectsBadConfig() { + // No bucket. + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("s3", Map.of()))); + // Credentials are required, never the server's own identity - together and individually. + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("s3", Map.of("bucket", BUCKET)))); + assertThrows( + IllegalArgumentException.class, + () -> + source.validate( + new InputSpec( + "s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA")))); + assertThrows( + IllegalArgumentException.class, + () -> source.validate(new InputSpec("s3", options(Map.of("mode", "sideways"))))); + assertThrows( + IllegalArgumentException.class, + () -> + source.validate( + new InputSpec( + "s3", options(Map.of("endpoint", "ftp://example.com"))))); + } + + @Test + void validateRejectsAnUnreachableBucket() { + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenThrow(SdkClientException.create("connection refused")); + + assertThrows(IllegalArgumentException.class, () -> source.validate(spec())); + } + + private static InputSpec spec() { + return new InputSpec("s3", options(Map.of())); + } + + /** The required options (bucket + credentials) plus any extras under test. */ + private static Map options(Map extra) { + Map options = new HashMap<>(extra); + options.put("bucket", BUCKET); + options.put("accessKeyId", "AKIAEXAMPLE"); + options.put("secretAccessKey", "shh"); + return options; + } + + private static S3Object object(String key, String eTag) { + return S3Object.builder() + .key(key) + .eTag(eTag) + .size(4L) + .lastModified(Instant.parse("2026-01-01T00:00:00Z")) + .build(); + } + + private void listingReturns(S3Object... objects) { + when(s3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenReturn(ListObjectsV2Response.builder().contents(objects).build()); + } + + private void headReturns(String key, String eTag) { + when(s3Client.headObject(any(HeadObjectRequest.class))) + .thenReturn( + HeadObjectResponse.builder() + .eTag(eTag) + .contentLength(4L) + .lastModified(Instant.parse("2026-01-01T00:00:00Z")) + .build()); + } + + private class RecordingContext implements ResolveContext { + + private final String policyId; + private final List present = new ArrayList<>(); + + private RecordingContext() { + this(POLICY); + } + + private RecordingContext(String policyId) { + this.policyId = policyId; + } + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(policyId, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java new file mode 100644 index 0000000000..a2a5a41ca0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java @@ -0,0 +1,211 @@ +package stirling.software.proprietary.policy.output; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.policy.input.ResolveContext; +import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.input.S3InputSource; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.model.InputSpec; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +/** + * End-to-end {@link S3OutputSink} test against a real S3 API (MinIO): uploads, collision renaming, + * and - composed with {@link S3InputSource} - the loop-safety guarantee that a policy writing into + * a bucket it also watches never re-ingests its own outputs, while a second policy still can. + */ +@Testcontainers(disabledWithoutDocker = true) +class S3OutputSinkMinioTest { + + private static final String POLICY = "p1"; + private static final String ACCESS_KEY = "minioadmin"; + private static final String SECRET_KEY = "minioadmin"; + + @Container + static MinIOContainer minio = + new MinIOContainer("minio/minio:latest") + .withUserName(ACCESS_KEY) + .withPassword(SECRET_KEY); + + private static S3Client adminClient; + private static int bucketCounter; + + private String bucket; + private S3OutputSink sink; + private S3InputSource source; + private InProcessProcessedLedger ledger; + + @BeforeEach + void setUp() { + if (adminClient == null) { + adminClient = + S3Client.builder() + .endpointOverride(URI.create(minio.getS3URL())) + .httpClient(UrlConnectionHttpClient.create()) + .region(Region.US_EAST_1) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY))) + .serviceConfiguration( + S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + } + bucket = "policy-outbox-" + ++bucketCounter; + adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build()); + + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(true); + S3ConnectionPool pool = new S3ConnectionPool(properties); + ledger = new InProcessProcessedLedger(); + sink = new S3OutputSink(pool, ledger); + source = new S3InputSource(pool); + } + + @Test + void uploadsOutputsUnderThePrefix() throws IOException { + List results = + sink.deliver( + new OutputDelivery("run-1", POLICY), + List.of(output("doc.pdf", "pdf bytes")), + outputSpec("processed/")); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/processed/doc.pdf"); + assertThat(objectContent("processed/doc.pdf")).isEqualTo("pdf bytes"); + } + + @Test + void anExistingKeyIsNeverOverwritten() throws IOException { + adminClient.putObject( + PutObjectRequest.builder().bucket(bucket).key("doc.pdf").build(), + RequestBody.fromString("theirs", StandardCharsets.UTF_8)); + + List results = + sink.deliver( + new OutputDelivery("run-1", POLICY), + List.of(output("doc.pdf", "ours")), + outputSpec("")); + + assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/doc (1).pdf"); + assertThat(objectContent("doc.pdf")).isEqualTo("theirs"); + assertThat(objectContent("doc (1).pdf")).isEqualTo("ours"); + } + + @Test + void aPolicyWritingIntoItsWatchedBucketSkipsItsOwnOutputsButAnotherPolicyChains() + throws IOException { + sink.deliver( + new OutputDelivery("run-1", POLICY), + List.of(output("result.pdf", "produced")), + outputSpec("")); + + // The producing policy's sweep sees its own output at the recorded gate and skips it. + assertThat(source.resolve(inputSpec(), new RecordingContext(POLICY))).isEmpty(); + + // A different policy watching the same bucket has no row and processes it - chaining. + List chained = source.resolve(inputSpec(), new RecordingContext("p2")); + assertThat(chained).hasSize(1); + try (InputStream stream = chained.get(0).inputs().primary().get(0).getInputStream()) { + assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8)) + .isEqualTo("produced"); + } + } + + private OutputSpec outputSpec(String prefix) { + return new OutputSpec( + "s3", + Map.of( + "bucket", bucket, + "prefix", prefix, + "endpoint", minio.getS3URL(), + "accessKeyId", ACCESS_KEY, + "secretAccessKey", SECRET_KEY)); + } + + private InputSpec inputSpec() { + return new InputSpec( + "s3", + Map.of( + "bucket", bucket, + "endpoint", minio.getS3URL(), + "accessKeyId", ACCESS_KEY, + "secretAccessKey", SECRET_KEY)); + } + + private String objectContent(String key) throws IOException { + try (ResponseInputStream stream = + adminClient.getObject(GetObjectRequest.builder().bucket(bucket).key(key).build())) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static Resource output(String name, String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public String getFilename() { + return name; + } + }; + } + + private class RecordingContext implements ResolveContext { + + private final String policyId; + + private RecordingContext(String policyId) { + this.policyId = policyId; + } + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(policyId, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) {} + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java new file mode 100644 index 0000000000..8240bcc4e1 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java @@ -0,0 +1,267 @@ +package stirling.software.proprietary.policy.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.policy.ledger.ClaimState; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; +import stirling.software.proprietary.policy.ledger.ProcessedFileStatus; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.s3.S3ConnectionPool; + +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * Tests for {@link S3OutputSink}: the ledger row exists before the object is visible, collisions + * re-pick names, ad-hoc runs record nothing, and encrypted-bucket ETags are re-recorded. + */ +@ExtendWith(MockitoExtension.class) +class S3OutputSinkTest { + + private static final String POLICY = "p1"; + private static final String BUCKET = "outbox-bucket"; + private static final OutputDelivery DELIVERY = new OutputDelivery("run-1", POLICY); + private static final OutputDelivery AD_HOC = new OutputDelivery("run-2", null); + + @Mock private S3Client s3Client; + + private S3OutputSink sink; + private InProcessProcessedLedger ledger; + private final List puts = new ArrayList<>(); + + @BeforeEach + void setUp() { + ledger = new InProcessProcessedLedger(); + sink = + new S3OutputSink( + new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + ledger); + } + + @Test + void recordsTheRowBeforeTheObjectBecomesVisible() throws IOException { + // The row for the exact key must already be settled DONE at the moment the PUT runs - + // record-before-visible, asserted from inside the upload itself. + List stateAtPutTime = new ArrayList<>(); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenAnswer( + invocation -> { + PutObjectRequest request = invocation.getArgument(0); + puts.add(request); + stateAtPutTime.add(stateFor(identity(request.key()))); + return PutObjectResponse.builder().eTag(quotedMd5("data")).build(); + }); + + List results = + sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()); + + assertEquals(1, results.size()); + assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName()); + assertEquals(4, results.get(0).getFileSize()); + assertNotNull(stateAtPutTime.get(0)); + assertEquals(ProcessedFileStatus.DONE, stateAtPutTime.get(0).status()); + assertEquals(md5("data"), stateAtPutTime.get(0).gate()); + assertTrue(puts.get(0).ifNoneMatch() != null); + } + + @Test + void aTakenKeyIsForgottenAndRePicked() throws IOException { + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenAnswer( + invocation -> { + PutObjectRequest request = invocation.getArgument(0); + puts.add(request); + if (puts.size() == 1) { + throw s3Error(412, "PreconditionFailed"); + } + return PutObjectResponse.builder().eTag(quotedMd5("data")).build(); + }); + + List results = + sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()); + + assertEquals("s3://" + BUCKET + "/processed/doc (1).pdf", results.get(0).getFileName()); + // The lost candidate's row is gone; only the delivered key is recorded. + assertNull(stateFor(identity("processed/doc.pdf"))); + assertNotNull(stateFor(identity("processed/doc (1).pdf"))); + } + + @Test + void anEncryptedBucketETagIsReRecordedAtTheActualGate() throws IOException { + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().eTag("\"kms-opaque-etag\"").build()); + + sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()); + + assertEquals("kms-opaque-etag", stateFor(identity("processed/doc.pdf")).gate()); + } + + @Test + void anAdHocDeliveryRecordsNothing() throws IOException { + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(PutObjectResponse.builder().eTag(quotedMd5("data")).build()); + + sink.deliver(AD_HOC, List.of(output("doc.pdf", "data")), spec()); + + assertNull(stateFor(identity("processed/doc.pdf"))); + } + + @Test + void aFailedUploadForgetsItsRowAndThrows() { + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenThrow(SdkClientException.create("connection refused")); + + assertThrows( + IOException.class, + () -> sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec())); + + assertNull(stateFor(identity("processed/doc.pdf"))); + } + + @Test + void aStoreWithoutConditionalPutsFallsBackToExistenceChecks() throws IOException { + when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(s3Error(404, "NotFound")); + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenAnswer( + invocation -> { + PutObjectRequest request = invocation.getArgument(0); + puts.add(request); + if (request.ifNoneMatch() != null) { + throw s3Error(501, "NotImplemented"); + } + return PutObjectResponse.builder().eTag(quotedMd5("data")).build(); + }); + + List results = + sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()); + + // Same key, second attempt unconditional. + assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName()); + assertEquals(2, puts.size()); + assertNull(puts.get(1).ifNoneMatch()); + assertNotNull(stateFor(identity("processed/doc.pdf"))); + } + + @Test + void aBarePrefixGetsItsSlash() throws IOException { + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenAnswer( + invocation -> { + puts.add(invocation.getArgument(0)); + return PutObjectResponse.builder().eTag(quotedMd5("data")).build(); + }); + + sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec("processed")); + + assertEquals("processed/doc.pdf", puts.get(0).key()); + } + + @Test + void validateRejectsBadConfigShape() { + assertThrows( + IllegalArgumentException.class, + () -> sink.validate(new OutputSpec("s3", Map.of()))); + // Credentials are required, never the server's own identity. + assertThrows( + IllegalArgumentException.class, + () -> sink.validate(new OutputSpec("s3", Map.of("bucket", BUCKET)))); + assertThrows( + IllegalArgumentException.class, + () -> + sink.validate( + new OutputSpec( + "s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA")))); + } + + @Test + void supportsOnlyS3Specs() { + assertTrue(sink.supports(spec())); + assertFalse(sink.supports(OutputSpec.inline())); + assertFalse(sink.supports(null)); + } + + private static OutputSpec spec() { + return spec("processed/"); + } + + private static OutputSpec spec(String prefix) { + return new OutputSpec( + "s3", + Map.of( + "bucket", + BUCKET, + "prefix", + prefix, + "accessKeyId", + "AKIAEXAMPLE", + "secretAccessKey", + "shh")); + } + + private static String identity(String key) { + return "s3://" + BUCKET + "/" + key; + } + + private ClaimState stateFor(String identity) { + return ledger.statesFor(POLICY, List.of(identity)).get(identity); + } + + private static Resource output(String name, String content) { + return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) { + @Override + public String getFilename() { + return name; + } + }; + } + + private static String md5(String content) { + try { + return HexFormat.of() + .formatHex( + MessageDigest.getInstance("MD5") + .digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String quotedMd5(String content) { + return "\"" + md5(content) + "\""; + } + + private static AwsServiceException s3Error(int status, String code) { + return S3Exception.builder().statusCode(status).message(code).build(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java index d1d62b7dbe..295b4785b8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceControllerTest.java @@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.store.InProcessPolicyStore; import stirling.software.proprietary.policy.store.PolicyStore; import stirling.software.proprietary.policy.trigger.PolicyTriggerManager; +import stirling.software.proprietary.util.SecretMasker; /** * Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is @@ -109,6 +110,107 @@ class SourceControllerTest { assertEquals(404, controller.delete("nope").getStatusCode().value()); } + @Test + void documentCountsForTheEditorReturnsTheTeamSeries() { + ResponseEntity> response = controller.documentCounts(EditorSource.ID); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(30, response.getBody().size()); + } + + @Test + void theEditorIsBuiltInAndCannotBeDeleted() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> controller.delete(EditorSource.ID)); + + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void theEditorIsBuiltInAndCannotBeSaved() { + Source editor = new Source(null, "Editor", "editor", Map.of(), true, null, null); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> controller.save(editor)); + + assertEquals(400, ex.getStatusCode().value()); + } + + @Test + void readsReturnSecretsAsTheRedactionSentinel() { + Source saved = sourceStore.save(s3Source("shh")); + + Source read = controller.get(saved.id()).getBody(); + + assertEquals(SecretMasker.REDACTED, read.options().get("secretAccessKey")); + assertEquals("AKIAEXAMPLE", read.options().get("accessKeyId")); + // The store itself keeps the real value. + assertEquals( + "shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey")); + } + + @Test + void savingTheSentinelBackKeepsTheStoredSecret() { + Source saved = sourceStore.save(s3Source("shh")); + + Source edited = + new Source( + saved.id(), + "Renamed", + saved.type(), + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", SecretMasker.REDACTED), + true, + saved.owner(), + saved.teamId()); + Source response = controller.save(edited).getBody(); + + assertEquals( + "shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey")); + // The save response is masked too; only the store sees the real value. + assertEquals(SecretMasker.REDACTED, response.options().get("secretAccessKey")); + } + + @Test + void savingANewSecretReplacesTheStoredOne() { + Source saved = sourceStore.save(s3Source("old-secret")); + + Source edited = + new Source( + saved.id(), + saved.name(), + saved.type(), + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "new-secret"), + true, + saved.owner(), + saved.teamId()); + controller.save(edited); + + assertEquals( + "new-secret", + sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey")); + } + + private static Source s3Source(String secret) { + return new Source( + null, + "Bucket intake", + "s3", + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", secret), + true, + "owner", + null); + } + private static Source folderSource() { return new Source( null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index 275f076db3..3f63e18407 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -56,9 +56,10 @@ class SourceOverviewServiceTest { SourcesResponse response = service.overview(); - assertEquals(3, response.sources().size()); - // Sorted most-referenced first, so the shared source A leads. - assertEquals(a.id(), response.sources().get(0).id()); + assertEquals(4, response.sources().size()); + // The built-in editor is pinned first; persisted sources follow, most-referenced leading. + assertEquals(EditorSource.ID, response.sources().get(0).id()); + assertEquals(a.id(), response.sources().get(1).id()); SourceView av = find(response, a.id()); assertEquals(2, av.referenceCount()); @@ -123,13 +124,57 @@ class SourceOverviewServiceTest { SourcesResponse response = scoped.overview(); - assertEquals(1, response.sources().size()); - SourceView view = response.sources().get(0); - assertEquals(ours.id(), view.id()); + assertEquals(2, response.sources().size()); + assertEquals(EditorSource.ID, response.sources().get(0).id()); + SourceView view = find(response, ours.id()); assertEquals(1, view.referenceCount()); assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList()); } + @Test + void theEditorSourceIsAlwaysPresentEvenWithNoConnections() { + SourcesResponse response = service.overview(); + + assertEquals(1, response.sources().size()); + SourceView editor = response.sources().get(0); + assertEquals(EditorSource.ID, editor.id()); + assertEquals("editor", editor.type()); + assertEquals("active", editor.status()); + assertEquals(0, editor.referenceCount()); + // KPIs describe configured connections, so the built-in editor is left out of them. + assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(SourceKpi::value).toList()); + } + + @Test + void theEditorSourceIsUsedByEveryPolicyThatRunsFromIt() { + editorPolicy("Redact on upload"); + editorPolicy("Classify on upload"); + // A folder-sourced policy does not target the editor, so it must not inflate the count. + policyReferencing("Folder sweep", source("Folder", "/f").id()); + + SourceView editor = find(service.overview(), EditorSource.ID); + + assertEquals(2, editor.referenceCount()); + assertTrue( + editor.referencingPolicies().stream() + .map(SourceView.PolicyRef::name) + .toList() + .containsAll(List.of("Redact on upload", "Classify on upload"))); + } + + @Test + void theEditorSourceReportsTheTeamsRecordedDocumentThroughput() { + // Login disabled, so the team is null and the editor shares the global counter bucket. + docCounter.record(EditorSource.counterKey(null), 4); + docCounter.record(EditorSource.counterKey(null), 6); + + SourceView editor = find(service.overview(), EditorSource.ID); + + assertEquals(10, editor.docsTotal()); + assertEquals(10, editor.docs24h()); + assertEquals(10, editor.docs30d()); + } + @Test void documentCountsReflectRecordedDocs() { Source a = source("A", "/a"); @@ -177,6 +222,22 @@ class SourceOverviewServiceTest { OutputSpec.inline())); } + /** + * A policy that targets the editor: membership rides in its output metadata, not a sourceId. + */ + private void editorPolicy(String name) { + policyStore.save( + new Policy( + null, + name, + "owner", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec("inline", Map.of("sources", List.of("editor"))))); + } + private void teamPolicy(String name, Long teamId, String... sourceIds) { policyStore.save( new Policy( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java index 889c50ee44..3f81392285 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/ScheduleTriggerTest.java @@ -70,6 +70,41 @@ class ScheduleTriggerTest { verify(policyRunner, times(1)).run(eq(policy)); } + @Test + void anIntervalMatchingTheSweepPeriodFiresEverySweepDespiteJitter() { + Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + + Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); + trigger.sweep(t0); // baseline + // The sweep that fires runs a few ms late (scheduler jitter)... + trigger.sweep(t0.plusSeconds(60).plusMillis(5)); + verify(policyRunner, times(1)).run(eq(policy)); + + // ...and the next sweep lands exactly on the 60s grid. Anchoring lastFired to the due + // time (not the jittered observation) means this must still fire, not alias to skip. + trigger.sweep(t0.plusSeconds(120)); + verify(policyRunner, times(2)).run(eq(policy)); + } + + @Test + void aGapFiresOnceNotOncePerMissedInterval() { + Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES)); + when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy)); + + Instant t0 = Instant.parse("2026-06-05T10:00:00Z"); + trigger.sweep(t0); // baseline + // Ten minutes of downtime: nine missed due points collapse into one firing. + trigger.sweep(t0.plusSeconds(600)); + verify(policyRunner, times(1)).run(eq(policy)); + + // Not due again until a full interval after the latest due point. + trigger.sweep(t0.plusSeconds(630)); + verify(policyRunner, times(1)).run(eq(policy)); + trigger.sweep(t0.plusSeconds(660)); + verify(policyRunner, times(2)).run(eq(policy)); + } + @Test void doesNotFireBeforeTheNextScheduledTime() { Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java index a90fde6b85..c2d14d1307 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java @@ -36,6 +36,7 @@ import jakarta.servlet.http.HttpServletResponse; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.audit.AuditContext; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -718,6 +719,57 @@ class AuditServiceTest { } } + @Nested + @DisplayName("addAutomationContext") + class AddAutomationContext { + + @Test + @DisplayName("caps and strips newlines from a spoofable policy-name header") + void capsPolicyNameHeader() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf"); + req.addHeader(InternalApiClient.POLICY_NAME_HEADER, "evil\r\nname" + "x".repeat(500)); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + String stored = (String) data.get("policyName"); + assertThat(stored).hasSize(200); + assertThat(stored).doesNotContain("\r").doesNotContain("\n"); + } + + @Test + @DisplayName("caps the number of persisted policy steps") + void capsPolicySteps() { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/policies/run"); + req.setAttribute( + AuditContext.REQ_ATTR_POLICY_STEPS, + java.util.stream.IntStream.range(0, 100) + .mapToObj(i -> "/api/v1/misc/step-" + i) + .toList()); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + @SuppressWarnings("unchecked") + java.util.List stored = (java.util.List) data.get("policySteps"); + assertThat(stored).hasSize(50); + } + + @Test + @DisplayName("marks automation from the header") + void marksAutomationHeader() { + MockHttpServletRequest req = + new MockHttpServletRequest("POST", "/api/v1/misc/compress-pdf"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + Map data = new HashMap<>(); + + service.addAutomationContext(data, req); + + assertThat(data).containsEntry("automation", Boolean.TRUE); + } + } + // ===== helpers ===== private ProceedingJoinPoint joinPoint() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java new file mode 100644 index 0000000000..b357fbc2e3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalAuditReadServiceTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +/** + * The scan must drop read/polling noise (UI_DATA/HTTP_REQUEST) at the query level. Otherwise a busy + * scope's recent rows fill with noise and the visible audit list shrinks as traffic grows. + */ +@ExtendWith(MockitoExtension.class) +class PortalAuditReadServiceTest { + + @Mock private PersistentAuditEventRepository repo; + + @InjectMocks private PortalAuditReadService service; + + private static Page emptyPage() { + return new PageImpl<>(List.of()); + } + + @Test + void serverScanExcludesReadNoiseAndNeverPullsEverything() { + when(repo.findByTypeNotIn(anyList(), any(Pageable.class))).thenReturn(emptyPage()); + + service.serverEvents(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> excluded = ArgumentCaptor.forClass(List.class); + verify(repo).findByTypeNotIn(excluded.capture(), any(Pageable.class)); + assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST"); + verify(repo, never()).findAll(any(Pageable.class)); + } + + @Test + void teamScanExcludesReadNoiseForTheTeamPrincipals() { + when(repo.findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class))) + .thenReturn(emptyPage()); + + service.scopedEvents("team:1", List.of("a@acme.com", "b@acme.com")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> excluded = ArgumentCaptor.forClass(List.class); + verify(repo) + .findByTypeNotInAndPrincipalIn(excluded.capture(), anyList(), any(Pageable.class)); + assertThat(excluded.getValue()).contains("UI_DATA", "HTTP_REQUEST"); + } + + @Test + void emptyTeamPrincipalsShortCircuitToNoQuery() { + assertThat(service.scopedEvents("team:1", List.of())).isEmpty(); + verify(repo, never()) + .findByTypeNotInAndPrincipalIn(anyList(), anyList(), any(Pageable.class)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java new file mode 100644 index 0000000000..1afc0af4a4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalDocumentsServiceTest.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.documents.PortalReviewDocumentDto; + +import tools.jackson.databind.json.JsonMapper; + +/** + * The Documents feed showed a policy run's internal steps as ordinary "API" traffic. They should + * read as automation so a policy run isn't confused with a customer's own API integration. + */ +@ExtendWith(MockitoExtension.class) +class PortalDocumentsServiceTest { + + @Mock private PortalAuditReadService auditReadService; + + private PortalDocumentsService service; + + @BeforeEach + void setUp() { + service = new PortalDocumentsService(auditReadService, JsonMapper.builder().build()); + } + + private static PortalAuditEventRow row(long id, String data) { + return new PortalAuditEventRow( + id, "con.yoh13@gmail.com", AuditEventType.PDF_PROCESS.name(), data, Instant.now()); + } + + private PortalReviewDocumentDto onlyDoc(String data) { + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + return service.serverDocuments().getDocuments().get(0); + } + + @Test + void policyStepDocumentIsLabelledAutomation() { + PortalReviewDocumentDto doc = + onlyDoc( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"__origin\":\"API\",\"files\":[{\"name\":\"mushroom life.pdf\"," + + "\"type\":\"application/pdf\"}],\"statusCode\":200}"); + + assertThat(doc.getName()).isEqualTo("mushroom life.pdf"); + assertThat(doc.getAction()).isEqualTo("Auto Redact"); + // Was product "API" (loopback used the API key); now clearly a policy automation step. + assertThat(doc.getProduct()).isEqualTo("Automation"); + assertThat(doc.getSource()).isEqualTo("Policy automation"); + } + + @Test + void directApiDocumentStaysApi() { + PortalReviewDocumentDto doc = + onlyDoc( + "{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\"," + + "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}]," + + "\"statusCode\":200}"); + + assertThat(doc.getProduct()).isEqualTo("API"); + assertThat(doc.getSource()).isEqualTo("API integration"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java new file mode 100644 index 0000000000..8d225367f3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PortalInfraAuditServiceTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.PortalAuditEventRow; +import stirling.software.proprietary.model.api.audit.InfraAuditEventDto; + +import tools.jackson.databind.json.JsonMapper; + +/** + * The Infrastructure → Audit tab reproduces the reported bug: a policy run showed as a bare "Run" + * row plus a separate "Auto Redact" row with no hint either belonged to a policy. + */ +@ExtendWith(MockitoExtension.class) +class PortalInfraAuditServiceTest { + + @Mock private PortalAuditReadService auditReadService; + + private PortalInfraAuditService service; + + @BeforeEach + void setUp() { + service = new PortalInfraAuditService(auditReadService, JsonMapper.builder().build()); + } + + private static PortalAuditEventRow row(long id, String data) { + return new PortalAuditEventRow( + id, + "con.yoh13@gmail.com", + AuditEventType.PDF_PROCESS.name(), + data, + Instant.parse("2026-07-09T10:18:24Z")); + } + + private InfraAuditEventDto onlyEvent(String data) { + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + return service.serverAuditLog().getEvents().get(0); + } + + @Test + void policyDispatchShowsPolicyNameAndTheToolsItRuns() { + String data = + "{\"path\":\"/api/v1/policies/run\",\"policyName\":\"Redaction\"," + + "\"policySteps\":[\"/api/v1/security/auto-redact\"," + + "\"/api/v1/misc/compress-pdf\"],\"statusCode\":202," + + "\"latencyMs\":5}"; + when(auditReadService.serverEvents()).thenReturn(List.of(row(1L, data))); + + var resp = service.serverAuditLog(); + InfraAuditEventDto e = resp.getEvents().get(0); + + // Was "Run" / "Run" under "processing"; now names the policy, lists what it ran, and + // badges as its own "policy" category with its own summary count. + assertThat(e.getAction()).isEqualTo("Redaction"); + assertThat(e.getCategory()).isEqualTo("policy"); + assertThat(e.getTarget()).isEqualTo("Auto Redact, Compress PDF"); + assertThat(resp.getSummary().getPolicy()).isEqualTo(1); + } + + @Test + void internalPipelineStepIsFlaggedAsAutomation() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"files\":[{\"name\":\"mushroom life.pdf\"}]," + + "\"statusCode\":200,\"latencyMs\":300}"); + + // Was a bare "Auto Redact" indistinguishable from a direct user action. + assertThat(e.getAction()).isEqualTo("Auto Redact (automation)"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("mushroom life.pdf"); + } + + @Test + void internalStepCarryingItsPolicyNameLinksBackToThePolicy() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/security/auto-redact\",\"automation\":true," + + "\"policyName\":\"Redaction demo\",\"files\":[{\"name\":" + + "\"demo.pdf\"}],\"statusCode\":200}"); + + // The forwarded policy name makes the step's origin unmistakable. + assertThat(e.getAction()).isEqualTo("Auto Redact (policy: Redaction demo)"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("demo.pdf"); + } + + @Test + void adHocRunWithoutNameStillReadsAsPolicyRun() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/policies/run/stream\",\"statusCode\":200," + + "\"latencyMs\":4}"); + + assertThat(e.getAction()).isEqualTo("Policy run"); + assertThat(e.getTarget()).isEqualTo("Pipeline"); + } + + @Test + void directToolRunIsUnchanged() { + InfraAuditEventDto e = + onlyEvent( + "{\"path\":\"/api/v1/misc/compress-pdf\"," + + "\"files\":[{\"name\":\"a.pdf\"}],\"statusCode\":200," + + "\"latencyMs\":100}"); + + assertThat(e.getAction()).isEqualTo("Compress PDF"); + assertThat(e.getCategory()).isEqualTo("processing"); + assertThat(e.getTarget()).isEqualTo("a.pdf"); + } + + /** + * A spoofed X-Stirling-Policy-Name header lands in audit data as policyName on a direct tool + * call (no automation marker, non-run path). It must NOT flip the row into a "policy" dispatch + * that overwrites the real action and hides the affected file - only a real /policies/.../run + * URI does that. + */ + @Test + void forgedPolicyNameOnDirectCallCannotMaskTheRealAction() { + when(auditReadService.serverEvents()) + .thenReturn( + List.of( + row( + 1L, + "{\"path\":\"/api/v1/security/remove-password\"," + + "\"policyName\":\"Daily cleanup\",\"files\":" + + "[{\"name\":\"secret.pdf\"}],\"statusCode\":200}"))); + + var resp = service.serverAuditLog(); + InfraAuditEventDto e = resp.getEvents().get(0); + + // Real op and file stay visible; the forged name does not become the action or category. + assertThat(e.getAction()).isEqualTo("Remove Password"); + assertThat(e.getCategory()).isEqualTo("security"); + assertThat(e.getTarget()).isEqualTo("secret.pdf"); + assertThat(resp.getSummary().getPolicy()).isEqualTo(0); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java index d91aec2029..415117a446 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java @@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test; * Unit tests for {@link SecretMasker}. * *

Assumptions: - Key matching is case-insensitive via the pattern in SENSITIVE. - If the key - * matches a sensitive pattern, the value is replaced with "***REDACTED***". - Nested maps and lists - * are searched recursively. - Null maps and null values are ignored or returned as null. - + * matches a sensitive pattern, the value is replaced with SecretMasker.REDACTED. - Nested maps and + * lists are searched recursively. - Null maps and null values are ignored or returned as null. - * Non-sensitive keys/values remain unchanged. */ class SecretMaskerTest { @@ -40,7 +40,7 @@ class SecretMaskerTest { Map result = SecretMasker.mask(input); - assertEquals("***REDACTED***", result.get("password")); + assertEquals(SecretMasker.REDACTED, result.get("password")); assertEquals("john", result.get("username")); } @@ -55,11 +55,54 @@ class SecretMaskerTest { Map result = SecretMasker.mask(input); - assertEquals("***REDACTED***", result.get("Api-Key")); - assertEquals("***REDACTED***", result.get("TOKEN")); + assertEquals(SecretMasker.REDACTED, result.get("Api-Key")); + assertEquals(SecretMasker.REDACTED, result.get("TOKEN")); assertEquals("keepme", result.get("normal")); } + @Test + @DisplayName("restoreRedacted swaps sentinels for stored values, leaves the rest") + void restoreRedactedRoundTripsAnEdit() { + Map stored = + Map.of("secretAccessKey", "shh", "accessKeyId", "AKIAEXAMPLE"); + Map incoming = + Map.of( + "secretAccessKey", SecretMasker.REDACTED, + "accessKeyId", "AKIA-NEW", + "bucket", "inbox"); + + Map merged = SecretMasker.restoreRedacted(incoming, stored); + + assertEquals("shh", merged.get("secretAccessKey")); + assertEquals("AKIA-NEW", merged.get("accessKeyId")); + assertEquals("inbox", merged.get("bucket")); + } + + @Test + @DisplayName("restoreRedacted leaves a sentinel with no stored counterpart in place") + void restoreRedactedWithoutStoredValueStaysSentinel() { + Map merged = + SecretMasker.restoreRedacted( + Map.of("secretAccessKey", SecretMasker.REDACTED), Map.of()); + + assertEquals(SecretMasker.REDACTED, merged.get("secretAccessKey")); + } + + @Test + @DisplayName("should mask camelCase secretAccessKey despite no word boundary") + void shouldMaskCamelCaseSecretAccessKey() { + Map input = + Map.of( + "secretAccessKey", "shh", + "accessKeyId", "AKIAEXAMPLE"); + + Map result = SecretMasker.mask(input); + + assertEquals(SecretMasker.REDACTED, result.get("secretAccessKey")); + // Access key ids are username-like, not secrets. + assertEquals("AKIAEXAMPLE", result.get("accessKeyId")); + } + @Test @DisplayName("should mask nested map sensitive keys") void shouldMaskNestedMapSensitiveKeys() { @@ -77,9 +120,9 @@ class SecretMaskerTest { Map result = SecretMasker.mask(input); Map outer = (Map) result.get("outer"); - assertEquals("***REDACTED***", outer.get("jwt")); + assertEquals(SecretMasker.REDACTED, outer.get("jwt")); Map inner = (Map) outer.get("inner"); - assertEquals("***REDACTED***", inner.get("secret")); + assertEquals(SecretMasker.REDACTED, inner.get("secret")); assertEquals("ok", inner.get("other")); } @@ -98,7 +141,7 @@ class SecretMaskerTest { List list = (List) result.get("list"); Map first = (Map) list.get(0); - assertEquals("***REDACTED***", first.get("token")); + assertEquals(SecretMasker.REDACTED, first.get("token")); Map second = (Map) list.get(1); assertEquals("john", second.get("username")); assertEquals("stringValue", list.get(2)); @@ -170,7 +213,8 @@ class SecretMaskerTest { Map outer = (Map) result.get("outer"); assertTrue(outer.containsKey(null), "Null key should be preserved"); assertEquals("plainText", outer.get(null), "Value for null key must not be masked"); - assertEquals("***REDACTED***", outer.get("password"), "Sensitive keys must be masked"); + assertEquals( + SecretMasker.REDACTED, outer.get("password"), "Sensitive keys must be masked"); } } } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 371060c032..28bbfbdea0 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -117,7 +117,8 @@ public class AiCreateController { user.getTeam().getId(), source, ProcessType.SINGLE_TOOL, - BillingCategory.AI); + BillingCategory.AI, + null); jobChargeService.chargeStandalone(ctx, 1); } catch (RuntimeException e) { log.warn( diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index 90616bea55..18da81ae2b 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -173,7 +173,9 @@ public class PaygWalletController { int spend = clampToInt(snap.periodSpendUnits()); Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null; - CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd()); + BreakdownPair breakdowns = buildBreakdowns(teamId, snap.periodStart(), snap.periodEnd()); + UsageAnalytics analytics = + buildUsageAnalytics(teamId, snap.periodStart(), snap.periodEnd()); // Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was // already netted out at charge time, so this is the metered total, not spend − grant. @@ -203,30 +205,63 @@ public class PaygWalletController { noCap, billing.subscriptionId(), spend, - breakdown, + breakdowns.units(), members, - buildActivity(teamId)); + buildActivity(teamId), + breakdowns.docs(), + analytics.docsProcessed(), + analytics.uniquePdfs(), + analytics.sizeMultiplierPdfs()); return ResponseEntity.ok(body); } - private CategoryBreakdown buildBreakdown( + /** Per-category size-scaled units + input-file counts for the same window. */ + private record BreakdownPair(CategoryBreakdown units, CategoryBreakdown docs) {} + + /** Period usage analytics: total input files, unique PDFs, and size-multiplier files. */ + private record UsageAnalytics(int docsProcessed, int uniquePdfs, int sizeMultiplierPdfs) {} + + private BreakdownPair buildBreakdowns( Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { - Map byCategory = new HashMap<>(); + Map units = new HashMap<>(); + Map docs = new HashMap<>(); for (Object[] row : - ledgerRepo.sumPeriodAmountByCategory( + ledgerRepo.sumPeriodByCategoryWithDocs( teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) { - if (row.length >= 2 - && row[0] instanceof BillingCategory cat - && row[1] instanceof Number n) { - byCategory.put(cat, n.longValue()); + if (row.length >= 3 && row[0] instanceof BillingCategory cat) { + if (row[1] instanceof Number u) { + units.put(cat, u.longValue()); + } + if (row[2] instanceof Number d) { + docs.put(cat, d.longValue()); + } } } + return new BreakdownPair(categoryBreakdown(units), categoryBreakdown(docs)); + } + + private static CategoryBreakdown categoryBreakdown(Map byCategory) { return new CategoryBreakdown( clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)), clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)), clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L))); } + private UsageAnalytics buildUsageAnalytics( + Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { + List rows = + ledgerRepo.periodUsageAnalytics( + teamId, LedgerEntryType.DEBIT, periodStart, periodEnd); + Object[] row = rows.isEmpty() ? null : rows.get(0); + return new UsageAnalytics(analyticsInt(row, 0), analyticsInt(row, 1), analyticsInt(row, 2)); + } + + private static int analyticsInt(Object[] row, int idx) { + return row != null && row.length > idx && row[idx] instanceof Number n + ? clampToInt(n.longValue()) + : 0; + } + /** * Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as * credits-back; system entries without a category render as {@code other}. @@ -446,6 +481,10 @@ public class PaygWalletController { 0, new CategoryBreakdown(0, 0, 0), List.of(), - Collections.emptyList()); + Collections.emptyList(), + new CategoryBreakdown(0, 0, 0), + 0, + 0, + 0); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java index 0b29347c85..8147bd030c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -75,7 +75,21 @@ public record WalletSnapshotResponse( int spendUnitsThisPeriod, CategoryBreakdown categoryBreakdown, List members, - List recent) { + List recent, + CategoryBreakdown categoryDocs, + int docsProcessedThisPeriod, + int uniquePdfsThisPeriod, + int sizeMultiplierPdfsThisPeriod) { + + // The count dimension, kept distinct from units (which now scale with file size): + // categoryDocs — per-category INPUT-file counts (parallel to + // categoryBreakdown, + // which stays the size-scaled unit totals) + // docsProcessedThisPeriod — total input files processed this period (Σ doc_count) + // uniquePdfsThisPeriod — distinct input documents (a file hit by N operations counts + // once) + // sizeMultiplierPdfsThisPeriod— input files on charges where the size multiplier applied + // (units billed > input files) /** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */ public record CategoryBreakdown(int api, int ai, int automation) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java index f5914f8536..55553318dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java @@ -14,13 +14,19 @@ import stirling.software.saas.payg.model.ProcessType; * the interceptor before this context is built. Manual UI tools never reach {@code openProcess} * (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore * carries one of {@code API}, {@code AI}, or {@code AUTOMATION}. + * + *

{@code runId} is the automation-run correlation id ({@code X-Stirling-Run-Id}) when this call + * is a sub-step of a pipeline / policy / AI-workflow run, else {@code null} (a standalone tool + * call). Lineage joins are scoped to a single run id: a null run id never joins (each standalone + * call is its own charge), and two separate runs never merge even on identical bytes. */ public record ChargeContext( Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType, - BillingCategory billingCategory) { + BillingCategory billingCategory, + String runId) { public ChargeContext { if (ownerUserId == null) { @@ -36,4 +42,17 @@ public record ChargeContext( throw new IllegalArgumentException("billingCategory is required"); } } + + /** + * Convenience for callers with no automation-run context — a standalone tool call ({@code + * runId} = {@code null}, so it never lineage-joins and is always its own charge). + */ + public ChargeContext( + Long ownerUserId, + Long ownerTeamId, + JobSource source, + ProcessType processType, + BillingCategory billingCategory) { + this(ownerUserId, ownerTeamId, source, processType, billingCategory, null); + } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index ea3bb9cdab..b71caddbee 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -113,7 +113,8 @@ public class JobChargeService { ctx.source(), ctx.processType(), policy.getId(), - stepLimit); + stepLimit, + ctx.runId()); List paths = inputs.stream().map(JobInput::path).toList(); JoinOrOpenResult result = jobService.joinOrOpen(jobCtx, paths); @@ -122,14 +123,23 @@ public class JobChargeService { return new ChargeOutcome(result.job().getId(), 0, ChargeOutcome.Disposition.JOINED); } + ProcessingJob job = result.job(); int units = computeUnits(inputs, policy); - result.job().setDocUnits(units); + job.setDocUnits(units); int freeUsed = consumeFreeGrant(ctx, units); - recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed); - recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units); + recordShadowRow(ctx, job.getId(), policy.getId(), units, freeUsed); + // doc_count + fingerprint were set on the fresh job by JobService.openFresh; carry them + // onto the ledger DEBIT so usage analytics query one table. + recordLedgerDebit( + ctx, + job.getId(), + policy.getId(), + units, + job.getDocCount(), + job.getDocumentFingerprint()); - return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED); + return new ChargeOutcome(job.getId(), units, ChargeOutcome.Disposition.OPENED); } /** @@ -164,12 +174,19 @@ public class JobChargeService { ctx.source(), ctx.processType(), policy.getId(), - stepLimit); + stepLimit, + ctx.runId()); ProcessingJob job = jobService.open(jobCtx, chargeUnits); int freeUsed = consumeFreeGrant(ctx, chargeUnits); recordShadowRow(ctx, job.getId(), policy.getId(), chargeUnits, freeUsed); - recordLedgerDebit(ctx, job.getId(), policy.getId(), chargeUnits); + recordLedgerDebit( + ctx, + job.getId(), + policy.getId(), + chargeUnits, + job.getDocCount(), + job.getDocumentFingerprint()); // Close immediately — nothing will lineage-join a standalone job — so the paid portion // meters via the same afterCommit hook + idempotency key as a normal process completion. @@ -216,7 +233,12 @@ public class JobChargeService { * Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed. */ private void recordLedgerDebit( - ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) { + ChargeContext ctx, + java.util.UUID jobId, + Long policyId, + int units, + int docCount, + String documentFingerprint) { BillingCategory category = ctx.billingCategory(); if (category == null || category == BillingCategory.BYPASSED) { return; @@ -231,6 +253,10 @@ public class JobChargeService { entry.setReferenceId(jobId.toString()); entry.setPolicyId(policyId); entry.setBillingCategory(category); + // Count dimension + input fingerprint, denormalised from the job for usage analytics + // (PDFs processed, unique PDFs, size-multiplier average). + entry.setDocCount(docCount); + entry.setDocumentFingerprint(documentFingerprint); ledgerRepository.save(entry); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index eda04a4789..b758c89787 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -33,6 +33,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.AutoJobPostMapping; +import stirling.software.common.service.AutomationRunContext; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.proprietary.security.database.repository.UserRepository; @@ -284,13 +285,25 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { request.setAttribute(ATTR_INPUT_BYTES, totalInputBytes); request.setAttribute(ATTR_TOOL_ID, resolveToolId(request)); + // Automation-run correlation id, honoured ONLY from an internal automation dispatch. + // InternalApiClient stamps X-Stirling-Automation on every loopback sub-step alongside the + // run id, so a genuine pipeline / policy / AI run always carries both. A raw external + // request that sets X-Stirling-Run-Id on its own is ignored (each such call stays its own + // charge): otherwise an API caller could pin a constant run id to collapse separate + // same-content calls into one charge, defeating "charge per API call". Null → standalone. + String headerRunId = request.getHeader(AutomationRunContext.RUN_ID_HEADER); + String runId = + (hasAutomationHeader(request) && headerRunId != null && !headerRunId.isBlank()) + ? headerRunId + : null; ChargeContext ctx = new ChargeContext( currentUser.getId(), currentUser.getTeam() == null ? null : currentUser.getTeam().getId(), determineSource(request, auth), ProcessType.SINGLE_TOOL, - category); + category, + runId); ChargeOutcome outcome; try { @@ -498,9 +511,20 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { } } + /** + * True when the request carries the internal-dispatch marker InternalApiClient stamps on every + * loopback sub-step ({@code X-Stirling-Automation: true}). This is the trust boundary for both + * the AUTOMATION billing category and for honouring {@code X-Stirling-Run-Id}: an external + * caller can't group charges via a run id without also declaring itself automation (which + * changes its own billing category). + */ + private static boolean hasAutomationHeader(HttpServletRequest request) { + String header = request.getHeader(AUTOMATION_HEADER); + return header != null && "true".equalsIgnoreCase(header.trim()); + } + private static JobSource determineSource(HttpServletRequest request, Authentication auth) { - String automationHeader = request.getHeader(AUTOMATION_HEADER); - if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { + if (hasAutomationHeader(request)) { return JobSource.PIPELINE; } String desktopHeader = request.getHeader(DESKTOP_CLIENT_HEADER); @@ -527,8 +551,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor { */ private static BillingCategory determineCategory( HandlerMethod handler, HttpServletRequest request, Authentication auth) { - String automationHeader = request.getHeader(AUTOMATION_HEADER); - if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) { + if (hasAutomationHeader(request)) { return BillingCategory.AUTOMATION; } RequiresFeature ann = diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java index 291a46880c..c672540870 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -116,7 +116,8 @@ public class InstanceUsageIngestService { teamId, JobSource.LINKED_INSTANCE, ProcessType.SINGLE_TOOL, - category), + category, + null), units); } if (row == null) { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java index 7840effa26..a025bc5c57 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobContext.java @@ -15,7 +15,8 @@ public record JobContext( JobSource source, ProcessType processType, Long policyId, - int stepLimit) { + int stepLimit, + String runId) { public JobContext { if (ownerUserId == null) { @@ -34,4 +35,19 @@ public record JobContext( throw new IllegalArgumentException("stepLimit must be > 0"); } } + + /** + * Convenience for callers with no automation-run context — a standalone tool call ({@code + * runId} = {@code null}, so {@code joinOrOpen} always opens a fresh process rather than + * lineage-joining). + */ + public JobContext( + Long ownerUserId, + Long ownerTeamId, + JobSource source, + ProcessType processType, + Long policyId, + int stepLimit) { + this(ownerUserId, ownerTeamId, source, processType, policyId, stepLimit, null); + } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java index 5addb5b76d..ab4739be6b 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/JobService.java @@ -1,12 +1,16 @@ package stirling.software.saas.payg.job; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; @@ -97,8 +101,14 @@ public class JobService { signaturesByInput.put(input, detector.extractSignatures(input)); } + // Lineage joins are scoped to one automation run: a standalone call (no run id) never + // joins — each is its own charge — and within a run, matching still runs by content so a + // run's separate input files each open their own charge (a merge of N inputs = N charges), + // while a single file's chain of steps + its split outputs collapse into one. Optional bestMatch = - findBestMatch(ctx.ownerUserId(), inputs, signaturesByInput); + ctx.runId() == null + ? Optional.empty() + : findBestMatch(ctx.ownerUserId(), ctx.runId(), inputs, signaturesByInput); if (bestMatch.isPresent()) { ProcessingJob existing = @@ -212,10 +222,13 @@ public class JobService { } private Optional findBestMatch( - Long userId, List inputs, Map> signaturesByInput) { + Long userId, + String runId, + List inputs, + Map> signaturesByInput) { List matches = new ArrayList<>(inputs.size()); for (Path input : inputs) { - detector.detect(userId, signaturesByInput.get(input)).ifPresent(matches::add); + detector.detect(userId, runId, signaturesByInput.get(input)).ifPresent(matches::add); } return matches.stream().max(Comparator.comparing(LineageMatch::jobLastStepAt)); } @@ -252,6 +265,11 @@ public class JobService { fresh.setProcessType(ctx.processType()); fresh.setSource(ctx.source()); fresh.setPolicyId(ctx.policyId()); + fresh.setRunId(ctx.runId()); + // doc_count = number of input files (the count dimension). A merge (N inputs in one call) + // is N; a split (1 input) is 1; a standalone bookkeeping job (no inputs) is 1. + fresh.setDocCount(Math.max(1, signaturesByInput.size())); + fresh.setDocumentFingerprint(computeFingerprint(signaturesByInput)); fresh.setStepCount(1); LocalDateTime now = LocalDateTime.now(); fresh.setStartedAt(now); @@ -262,6 +280,36 @@ public class JobService { return new JoinOrOpenResult(saved, JoinOrOpenResult.Disposition.OPENED); } + /** + * Stable fingerprint of this job's input set — SHA-256 over the sorted union of the inputs' + * lineage storage keys. {@code COUNT(DISTINCT ...)} over these gives "unique PDFs processed". + * {@code null} when there are no inputs (a standalone bookkeeping job, e.g. an AI Create + * session) — such jobs still count toward doc_count but aren't a distinct input PDF. + */ + private static String computeFingerprint(Map> signaturesByInput) { + List keys = + signaturesByInput.values().stream() + .flatMap(Set::stream) + .map(LineageSignature::asStorageKey) + .distinct() + .sorted() + .toList(); + if (keys.isEmpty()) { + return null; + } + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + for (String key : keys) { + md.update(key.getBytes(StandardCharsets.UTF_8)); + md.update((byte) 0); + } + return HexFormat.of().formatHex(md.digest()); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is guaranteed on every JRE; fall back to no fingerprint if it ever isn't. + return null; + } + } + private void recordAllInputs(UUID jobId, Map> signaturesByInput) { for (Set signatures : signaturesByInput.values()) { detector.record(jobId, signatures, ArtifactKind.INPUT); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java index d80bad16d4..08b73db6ac 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/ProcessingJob.java @@ -62,6 +62,27 @@ public class ProcessingJob implements Serializable { @Column(name = "doc_units", nullable = false) private Integer docUnits = 0; + /** + * Number of input files this charge represents — the count dimension, kept distinct from {@link + * #docUnits} (which scales with file size). A split (1 input → many outputs) stays 1; a merge + * (N inputs → 1 output) is N. Fixed at open; joined steps never change it. + * + *

The {@code columnDefinition} default keeps the ddl-auto ADD COLUMN safe on an + * already-populated {@code processing_job} (a bare {@code NOT NULL} add is rejected by Postgres + * on a non-empty table). + */ + @Column(name = "doc_count", nullable = false, columnDefinition = "integer not null default 1") + private Integer docCount = 1; + + /** + * Correlation id of the automation run that opened this job ({@code X-Stirling-Run-Id}), or + * {@code null} for a standalone tool call. Lineage joins are scoped to a single run id, so two + * separate runs never merge even on identical bytes; a null run id never joins (each standalone + * call is its own charge). + */ + @Column(name = "run_id", length = 64) + private String runId; + @Column(name = "step_count", nullable = false) private Integer stepCount = 0; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java index 082054a09f..c9b939b975 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/DefaultHashLineageDetector.java @@ -73,6 +73,21 @@ public class DefaultHashLineageDetector implements HashLineageDetector { return store.findOpenJobForSignatures(userId, signatures, workflowWindow); } + @Override + public Optional detect( + Long userId, String runId, Set signatures) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(signatures, "signatures"); + if (runId == null) { + // No run context → standalone call; never lineage-joins (each is its own charge). + return Optional.empty(); + } + if (signatures.isEmpty()) { + return Optional.empty(); + } + return store.findOpenJobForSignatures(userId, signatures, workflowWindow, runId); + } + @Override public void record(UUID jobId, Path file, ArtifactKind kind) throws IOException { Objects.requireNonNull(file, "file"); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java index 66559eaf40..2973ad8cf0 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/HashLineageDetector.java @@ -50,6 +50,17 @@ public interface HashLineageDetector { */ Optional detect(Long userId, Set signatures); + /** + * Run-scoped detect: as {@link #detect(Long, Set)} but only matches open jobs belonging to the + * automation run {@code runId}, so lineage joins never cross runs (two separate runs on + * identical bytes stay distinct charges). The default ignores {@code runId} (for simple test + * doubles); the production detector overrides it to scope the store lookup. + */ + default Optional detect( + Long userId, String runId, Set signatures) { + return detect(userId, signatures); + } + /** * Same as {@link #record(UUID, Path, ArtifactKind)} but operating on pre-computed signatures. * Empty {@code signatures} is a no-op. diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java index 7ab46d9bd0..c970da34f8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JobLineageStore.java @@ -34,6 +34,19 @@ public interface JobLineageStore { Optional findOpenJobForSignatures( Long userId, Set candidates, Duration workflowWindow); + /** + * Run-scoped variant: as {@link #findOpenJobForSignatures(Long, Set, Duration)} but + * additionally constrained to open jobs whose {@code run_id} equals {@code runId}. Lineage + * joins are scoped to a single automation run, so a pipeline's sub-steps group into one charge + * while two separate runs on identical bytes stay distinct. The default delegates to the + * unscoped lookup (for test doubles that don't model run ids); the production store overrides + * it with a filtered query. + */ + default Optional findOpenJobForSignatures( + Long userId, Set candidates, Duration workflowWindow, String runId) { + return findOpenJobForSignatures(userId, candidates, workflowWindow); + } + /** Deletes records created before {@code cutoff}. Returns the number of rows removed. */ int pruneOlderThan(Instant cutoff); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java index 4da4cd0b52..09df6ed12f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/JpaJobLineageStore.java @@ -78,6 +78,24 @@ public class JpaJobLineageStore implements JobLineageStore { return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)); } + @Override + public Optional findOpenJobForSignatures( + Long userId, Set candidates, Duration workflowWindow, String runId) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(candidates, "candidates"); + Objects.requireNonNull(workflowWindow, "workflowWindow"); + Objects.requireNonNull(runId, "runId"); + if (candidates.isEmpty()) { + return Optional.empty(); + } + List storageKeys = candidates.stream().map(LineageSignature::asStorageKey).toList(); + LocalDateTime since = LocalDateTime.now().minus(workflowWindow); + List matches = + hashRepository.findOpenJobsForSignaturesInRun( + userId, JobStatus.OPEN, since, storageKeys, runId, Limit.of(1)); + return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0)); + } + @Override @Transactional public int pruneOlderThan(Instant cutoff) { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java index 0c3af74dbe..7b02dbaebc 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/JobArtifactHashRepository.java @@ -43,6 +43,29 @@ public interface JobArtifactHashRepository @Param("signatures") Collection signatures, Limit limit); + /** + * Run-scoped lineage lookup: as {@link #findOpenJobsForSignatures} but additionally requires + * {@code j.run_id = :runId}, so only jobs belonging to the same automation run can be joined. + */ + @Query( + "SELECT new stirling.software.saas.payg.lineage.LineageMatch(" + + " h.id.jobId, h.id.kind, j.lastStepAt)" + + " FROM JobArtifactHash h" + + " JOIN ProcessingJob j ON j.id = h.id.jobId" + + " WHERE j.ownerUserId = :userId" + + " AND j.status = :openStatus" + + " AND j.lastStepAt > :since" + + " AND j.runId = :runId" + + " AND h.id.contentHash IN :signatures" + + " ORDER BY j.lastStepAt DESC") + List findOpenJobsForSignaturesInRun( + @Param("userId") Long userId, + @Param("openStatus") JobStatus openStatus, + @Param("since") LocalDateTime since, + @Param("signatures") Collection signatures, + @Param("runId") String runId, + Limit limit); + /** Prunes rows older than {@code cutoff}; run from a scheduled task. */ @Modifying @Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff") diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java index 095f6cb924..f307d54241 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -18,14 +18,13 @@ public interface WalletLedgerRepository extends JpaRepository findTop20ByTeamIdOrderByIdDesc(Long teamId); /** - * Per-category debit totals over an arbitrary window, as positive units. Replaces the - * calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed - * teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows - * with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger - * by construction. + * Per-category debit totals with BOTH the size-scaled unit sum and the input-file count ({@code + * doc_count}) over a window. Rows: {@code [category, units, docs]}. Lets the wallet show, per + * category, "X PDFs · Y meter units" rather than conflating the two. */ @Query( - "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units" + "SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units," + + " COALESCE(SUM(e.docCount), 0) AS docs" + " FROM WalletLedgerEntry e" + " WHERE e.teamId = :teamId" + " AND e.entryType = :entryType" @@ -33,7 +32,36 @@ public interface WalletLedgerRepository extends JpaRepository= :periodStart" + " AND e.occurredAt < :periodEnd" + " GROUP BY e.billingCategory") - List sumPeriodAmountByCategory( + List sumPeriodByCategoryWithDocs( + @Param("teamId") Long teamId, + @Param("entryType") LedgerEntryType entryType, + @Param("periodStart") LocalDateTime periodStart, + @Param("periodEnd") LocalDateTime periodEnd); + + /** + * Period usage analytics in one row: {@code [docsProcessed, uniquePdfs, sizeMultiplierPdfs]}. + * {@code docsProcessed} sums input-file counts; {@code uniquePdfs} counts distinct input + * fingerprints (a file hit by N operations counts once); {@code sizeMultiplierPdfs} sums the + * input files on charges where the size multiplier kicked in (units billed > input files). + * DEBIT + non-null category only. + * + *

Returns a single-element {@code List} (aggregate-only query → always one row). Declared as + * {@code List} rather than {@code Object[]}: Spring Data treats an {@code Object[]} + * return as a collection and hands back {@code Object[]{ row }}, so the caller would + * read the columns one level too deep — take {@code get(0)}. + */ + @Query( + "SELECT COALESCE(SUM(e.docCount), 0) AS docs," + + " COUNT(DISTINCT e.documentFingerprint) AS uniquePdfs," + + " COALESCE(SUM(CASE WHEN (-e.amountUnits) > e.docCount THEN e.docCount ELSE 0" + + " END), 0) AS sizeMultiplierPdfs" + + " FROM WalletLedgerEntry e" + + " WHERE e.teamId = :teamId" + + " AND e.entryType = :entryType" + + " AND e.billingCategory IS NOT NULL" + + " AND e.occurredAt >= :periodStart" + + " AND e.occurredAt < :periodEnd") + List periodUsageAnalytics( @Param("teamId") Long teamId, @Param("entryType") LedgerEntryType entryType, @Param("periodStart") LocalDateTime periodStart, diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 4971e296f3..fab6647b2c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -74,6 +74,26 @@ public class WalletLedgerEntry implements Serializable { @Column(name = "policy_id") private Long policyId; + /** + * Number of input files this entry billed (the count dimension, distinct from size-scaled + * {@link #amountUnits}). Denormalised from {@code processing_job.doc_count} so usage analytics + * — "PDFs processed" — sum one table. Defaults to 1; system/aggregate entries may leave it 1. + * + *

The {@code columnDefinition} default keeps the ddl-auto ADD COLUMN safe on an + * already-populated {@code wallet_ledger} (a bare {@code NOT NULL} add is rejected by Postgres + * on a non-empty table). + */ + @Column(name = "doc_count", nullable = false, columnDefinition = "integer not null default 1") + private Integer docCount = 1; + + /** + * SHA-256 of this entry's input file set; {@code COUNT(DISTINCT ...)} over a period gives + * unique PDFs processed. {@code null} for aggregate/system entries (grants, linked-instance + * sync) that don't map to a single document set. + */ + @Column(name = "document_fingerprint", length = 64) + private String documentFingerprint; + @Column(name = "stripe_event_id", length = 128) private String stripeEventId; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 57f7e6a3a3..d67a0fddbf 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -32,6 +32,8 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.model.QuoteDetails; +import stirling.software.saas.procurement.pricing.ProcurementPricingService; import stirling.software.saas.procurement.pricing.QuoteConfig; import stirling.software.saas.procurement.pricing.QuoteLineItem; import stirling.software.saas.procurement.service.ProcurementService; @@ -56,16 +58,19 @@ public class ProcurementController { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private final ProcurementService procurement; + private final ProcurementPricingService pricing; private final TeamMembershipRepository memberRepo; private final UserRepository userRepository; private final ProcurementConfigurationProperties config; public ProcurementController( ProcurementService procurement, + ProcurementPricingService pricing, TeamMembershipRepository memberRepo, UserRepository userRepository, ProcurementConfigurationProperties config) { this.procurement = Objects.requireNonNull(procurement); + this.pricing = Objects.requireNonNull(pricing); this.memberRepo = Objects.requireNonNull(memberRepo); this.userRepository = Objects.requireNonNull(userRepository); this.config = Objects.requireNonNull(config); @@ -77,29 +82,53 @@ public class ProcurementController { long volume, int users, int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed + double sizeMult, // PDF-size tier multiplier: 1.0 / 1.4 / 2.4; 0 → no uplift String deployment, int termYears, String serviceLevel, boolean indemnification, boolean training, boolean qbr, - boolean offlineLicense, String currency, - String businessName) { + String businessName, + // Buyer / AP details (all optional). Country + currency intentionally out of scope. + String contactName, + String contactEmail, + String addressLine1, + String addressLine2, + String city, + String region, + String postalCode, + String poNumber, + String taxId) { QuoteConfig toConfig() { return new QuoteConfig( volume, users, intensity, + sizeMult, deployment, termYears, serviceLevel, indemnification, training, qbr, - offlineLicense, currency); } + + QuoteDetails toDetails() { + return new QuoteDetails( + businessName, + contactName, + contactEmail, + addressLine1, + addressLine2, + city, + region, + postalCode, + poNumber, + taxId); + } } public record QuoteResponse( @@ -109,10 +138,15 @@ public class ProcurementController { String currency, long annualNetMinor, long tcvMinor, + // First post-term renewal fee after the CPI escalator, and that escalator as a whole + // percent — the committed term is flat, so these describe only the auto-renewal. + long renewalAnnualNetMinor, + int cpiRatePct, List lineItems, String validUntil, String stripeQuoteId, String invoiceUrl, + String invoicePdf, QuoteConfigEcho config) {} /** @@ -124,19 +158,33 @@ public class ProcurementController { long volume, int users, int intensity, + double sizeMult, String deployment, int termYears, String serviceLevel, boolean indemnification, boolean training, boolean qbr, - boolean offlineLicense, String currency, - String businessName) {} + String businessName, + String contactName, + String contactEmail, + String addressLine1, + String addressLine2, + String city, + String region, + String postalCode, + String poNumber, + String taxId) {} + + /** Trial setup captured before the trial starts: deployment target + seat count. */ + public record StartTrialRequest(String deployment, int users) {} public record SnapshotResponse( Long dealId, String stage, + String deployment, + int seats, String trialStartedAt, String trialEndsAt, int trialExtensionsUsed, @@ -165,12 +213,12 @@ public class ProcurementController { } private static final SnapshotResponse EMPTY_SNAPSHOT = - new SnapshotResponse(null, null, null, null, 0, false, null, null); + new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null); /** - * Download the offline / air-gapped licence file (.lic) for the team, when the paid offline - * add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak - * that a licence exists to a team without the add-on. + * Download the offline / air-gapped licence file (.lic) for the team — available for an + * air-gapped deployment from the trial licence onward. 404 when there's no licence yet or the + * deployment isn't air-gapped, so we don't leak that a licence exists. */ @GetMapping("/license/file") @PreAuthorize("isAuthenticated()") @@ -193,10 +241,15 @@ public class ProcurementController { @PostMapping("/trial/start") @PreAuthorize("isAuthenticated()") - public ResponseEntity startTrial(Authentication auth) { + public ResponseEntity startTrial( + @RequestBody(required = false) StartTrialRequest request, Authentication auth) { Long teamId = requireLeader(auth); if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true)); + // Body is optional so an older client (no setup step) still starts a cloud trial. + String deployment = request != null ? request.deployment() : null; + int seats = request != null ? request.users() : 0; + return ResponseEntity.ok( + toSnapshot(procurement.startTrial(teamId, deployment, seats), true)); } @PostMapping("/trial/extend") @@ -218,9 +271,7 @@ public class ProcurementController { Long teamId = requireLeader(auth); if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.ok( - toQuote( - procurement.buildQuote( - teamId, request.toConfig(), request.businessName()))); + toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails()))); } // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a @@ -323,6 +374,8 @@ public class ProcurementController { return new SnapshotResponse( deal.getDealId(), deal.getStage(), + deal.getDeployment(), + deal.getSeats(), str(deal.getTrialStartedAt()), str(deal.getTrialEndsAt()), deal.getTrialExtensionsUsed(), @@ -339,23 +392,40 @@ public class ProcurementController { q.getCurrency(), q.getAnnualNetMinor(), q.getTcvMinor(), + // Prefer the renewal locked at quote time; fall back to a live projection for + // quotes + // priced before the column existed. + q.getRenewalAnnualMinor() > 0 + ? q.getRenewalAnnualMinor() + : pricing.renewalAnnualMinor(q.getAnnualNetMinor()), + pricing.cpiRatePct(), parseLineItems(q.getLineItemsJson()), q.getValidUntil() == null ? null : q.getValidUntil().toString(), q.getStripeQuoteId(), q.getStripeInvoiceUrl(), + q.getStripeInvoicePdf(), new QuoteConfigEcho( q.getVolume(), 0, q.getIntensity(), + q.getSizeMult(), q.getDeployment(), q.getTermYears(), q.getServiceLevel(), q.isIndemnification(), q.isTraining(), q.isQbr(), - q.isOfflineLicense(), q.getCurrency(), - q.getBusinessName())); + q.getBusinessName(), + q.getContactName(), + q.getContactEmail(), + q.getAddressLine1(), + q.getAddressLine2(), + q.getCity(), + q.getRegion(), + q.getPostalCode(), + q.getPoNumber(), + q.getTaxId())); } private List parseLineItems(String json) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java index f97d66f365..3a5d3db902 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java @@ -51,6 +51,15 @@ public class ProcurementDeal implements Serializable { @Column(name = "stage", nullable = false, length = 32) private String stage = STAGE_TRIAL; + // Deployment target + seat count captured at trial start (the setup step); they seed the quote + // builder so it opens on the buyer's real environment. The quote remains the commercial source + // of truth — these are just the starting point, editable when the quote is built. + @Column(name = "deployment", nullable = false, length = 16) + private String deployment = "cloud"; + + @Column(name = "seats", nullable = false) + private int seats; + @Column(name = "trial_started_at") private LocalDateTime trialStartedAt; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java index dd635a2cf6..aa94128172 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java @@ -66,6 +66,10 @@ public class ProcurementQuote implements Serializable { @Column(name = "intensity", nullable = false) private int intensity = 4; + /** File-size tier multiplier on the rate (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */ + @Column(name = "size_mult", nullable = false) + private double sizeMult = 1.0; + @Column(name = "deployment", length = 24) private String deployment; @@ -84,15 +88,17 @@ public class ProcurementQuote implements Serializable { @Column(name = "qbr", nullable = false) private boolean qbr; - @Column(name = "offline_license", nullable = false) - private boolean offlineLicense; - @Column(name = "annual_net_minor", nullable = false) private long annualNetMinor; @Column(name = "tcv_minor", nullable = false) private long tcvMinor; + // First post-term renewal fee (annual net + one CPI step), locked at quote time so the buyer's + // quoted renewal doesn't drift if the rate card changes later. + @Column(name = "renewal_annual_minor", nullable = false) + private long renewalAnnualMinor; + @Column(name = "line_items", columnDefinition = "text") private String lineItemsJson; @@ -105,10 +111,47 @@ public class ProcurementQuote implements Serializable { @Column(name = "stripe_invoice_url", columnDefinition = "text") private String stripeInvoiceUrl; + // Direct PDF link for that first invoice (Stripe invoice_pdf), set at accept alongside the URL; + // persisted so the portal's download button works after a reload, not just in the accept + // response. + @Column(name = "stripe_invoice_pdf", columnDefinition = "text") + private String stripeInvoicePdf; + // Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it. @Column(name = "business_name", length = 255) private String businessName; + // Buyer/AP details captured on the quote's "Your details" step. All optional — they never gate + // quote generation; they flow onto the Stripe customer (name + bill-to address) and the invoice + // (PO number + tax id as invoice custom fields), and seed the builder on a re-edit. Country and + // currency are intentionally out of scope for now. + @Column(name = "contact_name", length = 255) + private String contactName; + + @Column(name = "contact_email", length = 255) + private String contactEmail; + + @Column(name = "address_line1", length = 255) + private String addressLine1; + + @Column(name = "address_line2", length = 255) + private String addressLine2; + + @Column(name = "city", length = 128) + private String city; + + @Column(name = "region", length = 128) + private String region; + + @Column(name = "postal_code", length = 32) + private String postalCode; + + @Column(name = "po_number", length = 128) + private String poNumber; + + @Column(name = "tax_id", length = 64) + private String taxId; + @Column(name = "valid_until") private LocalDate validUntil; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java new file mode 100644 index 0000000000..18683c5907 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/QuoteDetails.java @@ -0,0 +1,21 @@ +package stirling.software.saas.procurement.model; + +/** + * Buyer / AP details captured on the quote's "Your details" step: the company and signatory + * contact, a billing address, and a PO number / tax id for the invoice. These are not pricing + * inputs (they never touch {@link stirling.software.saas.procurement.pricing.QuoteConfig}); they + * ride alongside the priced config so the quote can be re-seeded on an edit and the fields can flow + * onto the Stripe customer and invoice. All fields are optional. Country and currency are out of + * scope for now. + */ +public record QuoteDetails( + String businessName, + String contactName, + String contactEmail, + String addressLine1, + String addressLine2, + String city, + String region, + String postalCode, + String poNumber, + String taxId) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java index d10f216e8a..61dc5f499a 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java @@ -21,7 +21,8 @@ public record PricingRates( long selfHostDeployMinor, // flat: self-hosted deployment long airgapDeployMinor, // flat: air-gapped deployment long qbrAnnualMinor, // flat: quarterly business reviews - long trainingOneTimeMinor) { // one-time: onboarding & training + long trainingOneTimeMinor, // one-time: onboarding & training + double cpiEscalator) { // fixed CPI uplift on the annual fee at each post-term renewal public static PricingRates defaults() { return new PricingRates( @@ -34,7 +35,8 @@ public record PricingRates( 1_200_000, // self-hosted $12,000 / yr 3_600_000, // air-gapped $36,000 / yr 800_000, // QBRs $8,000 / yr - 750_000); // onboarding & training $7,500 one-time + 750_000, // onboarding & training $7,500 one-time + 0.03); // 3% CPI escalator per renewal (committed term stays flat) } public double termDiscount(int termYears) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java index 4707c94197..08ade5a157 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java @@ -61,6 +61,11 @@ public class ProcurementPricingService { * (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2)) : 0.0; double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc)); + // File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds + // into the per-run rate after the floor, so it flows through the meter, TCV and renewal. + // QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a + // cheaper factor in. + rate *= cfg.sizeMult(); double termDisc = rates.termDiscount(cfg.termYears()); // The meter is a whole-dollar figure (the quote reads in dollars), then minor units. @@ -82,6 +87,7 @@ public class ProcurementPricingService { long annualNet = meterNetMinor + support + deploy + indemnity + qbr; long tcv = annualNet * cfg.termYears() + training; + long renewalAnnual = renewalAnnualMinor(annualNet, rates); double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run @@ -151,7 +157,29 @@ public class ProcurementPricingService { QuoteLineItem.Kind.ONE_TIME, training)); } - return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency()); + return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency()); + } + + /** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */ + public double cpiEscalator() { + return PricingRates.defaults().cpiEscalator(); + } + + /** The CPI escalator as a whole-percent figure for buyer-facing copy (3% → 3). */ + public int cpiRatePct() { + return (int) Math.round(cpiEscalator() * 100.0); + } + + /** + * The escalated annual fee at the first renewal: the committed annual plus one CPI step. Used + * both when pricing a fresh quote and when echoing a stored one, so the two always agree. + */ + public long renewalAnnualMinor(long annualNetMinor) { + return renewalAnnualMinor(annualNetMinor, PricingRates.defaults()); + } + + private static long renewalAnnualMinor(long annualNetMinor, PricingRates rates) { + return Math.round(annualNetMinor * (1.0 + rates.cpiEscalator())); } private static long deployFeeMinor(String deployment, PricingRates rates) { diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java index 10ffde5271..05b136f539 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java @@ -3,10 +3,15 @@ package stirling.software.saas.procurement.pricing; import java.util.List; /** - * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the + * The priced result of a {@link QuoteConfig}: the itemised lines plus the headline figures the * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee - * after the multi-year discount; {@code tcvMinor} is total contract value across the term including - * one-time fees. Minor units (cents). + * after the multi-year discount; {@code tcvMinor} is total contract value across the committed term + * including one-time fees; {@code renewalAnnualNetMinor} is the annual fee at the first post-term + * renewal after the fixed CPI escalator (the committed term itself is flat). Minor units (cents). */ public record QuoteBreakdown( - List lineItems, long annualNetMinor, long tcvMinor, String currency) {} + List lineItems, + long annualNetMinor, + long tcvMinor, + long renewalAnnualNetMinor, + String currency) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java index cc6612c530..7511f2ff95 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java @@ -10,23 +10,35 @@ public record QuoteConfig( long volume, // committed PDFs per year int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden) int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7 + double sizeMult, // file-size tier multiplier on the rate — Compact 1.0 / Standard 1.4 / + // Heavy 2.4 String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial) int termYears, // 1..5 String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee) boolean indemnification, boolean training, boolean qbr, - boolean offlineLicense, // offline .lic availability (gates download; no longer priced here) String currency) { // USD only for now /** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */ public static final int DEFAULT_INTENSITY = 4; + /** Known file-size tier multipliers (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */ + private static final double[] SIZE_MULTS = {1.0, 1.4, 2.4}; + public QuoteConfig { if (termYears < 1) termYears = 1; if (termYears > 5) termYears = 5; if (intensity < 1) intensity = DEFAULT_INTENSITY; if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard"; if (currency == null || currency.isBlank()) currency = "USD"; + // Snap the file-size multiplier to a known tier so a tampered request can't invent a + // cheaper + // one; absent (0.0) or unknown falls back to 1.0 (no uplift). + double snapped = 1.0; + for (double s : SIZE_MULTS) { + if (Math.abs(s - sizeMult) < 1e-9) snapped = s; + } + sizeMult = snapped; } } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 225e0327e8..cae1cf36fb 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -24,6 +24,7 @@ import stirling.software.saas.procurement.license.EnterpriseLicenseService; import stirling.software.saas.procurement.license.LicenseEntitlements; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.model.QuoteDetails; import stirling.software.saas.procurement.pricing.ProcurementPricingService; import stirling.software.saas.procurement.pricing.QuoteBreakdown; import stirling.software.saas.procurement.pricing.QuoteConfig; @@ -96,28 +97,46 @@ public class ProcurementService { /** * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the - * Keygen licence, and the deal row is the journey state. + * Keygen licence, and the deal row is the journey state. The buyer's chosen deployment target + * ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote + * builder opens seeded to their environment; both are still editable when the quote is built. */ @Transactional - public ProcurementDeal startTrial(Long teamId) { + public ProcurementDeal startTrial(Long teamId, String deployment, int seats) { ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); LocalDateTime now = LocalDateTime.now(); LocalDateTime ends = now.plusDays(config.getTrialDurationDays()); deal.setStage(ProcurementDeal.STAGE_TRIAL); + deal.setDeployment(normalizeDeployment(deployment)); + deal.setSeats(Math.max(0, seats)); deal.setTrialStartedAt(now); deal.setTrialEndsAt(ends); deal.setTrialExtensionsUsed(0); deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends)); deal = dealRepo.save(deal); log.info( - "[procurement] trial started team={} deal={} ends={}", + "[procurement] trial started team={} deal={} deployment={} seats={} ends={}", teamId, deal.getDealId(), + deal.getDeployment(), + deal.getSeats(), ends); return deal; } + /** + * Constrain a caller-supplied deployment to the known set; anything else falls back to cloud. + */ + private static String normalizeDeployment(String deployment) { + if (deployment == null) return "cloud"; + String d = deployment.trim().toLowerCase(Locale.ROOT); + return switch (d) { + case "selfhost", "airgap", "cloud" -> d; + default -> "cloud"; + }; + } + /** Extend the current trial by the configured increment, up to the cap. */ @Transactional public ProcurementDeal extendTrial(Long teamId) { @@ -145,7 +164,7 @@ public class ProcurementService { /** Price a quote config server-side and persist it as a draft against the team's deal. */ @Transactional - public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) { + public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, QuoteDetails details) { ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) { @@ -168,16 +187,26 @@ public class ProcurementService { quote.setVolume(cfg.volume()); quote.setSeats(cfg.users() > 0 ? cfg.users() : null); quote.setIntensity(cfg.intensity()); + quote.setSizeMult(cfg.sizeMult()); quote.setDeployment(cfg.deployment()); quote.setTermYears(cfg.termYears()); quote.setServiceLevel(cfg.serviceLevel()); quote.setIndemnification(cfg.indemnification()); quote.setTraining(cfg.training()); quote.setQbr(cfg.qbr()); - quote.setOfflineLicense(cfg.offlineLicense()); - quote.setBusinessName(businessName); + quote.setBusinessName(details.businessName()); + quote.setContactName(details.contactName()); + quote.setContactEmail(details.contactEmail()); + quote.setAddressLine1(details.addressLine1()); + quote.setAddressLine2(details.addressLine2()); + quote.setCity(details.city()); + quote.setRegion(details.region()); + quote.setPostalCode(details.postalCode()); + quote.setPoNumber(details.poNumber()); + quote.setTaxId(details.taxId()); quote.setAnnualNetMinor(breakdown.annualNetMinor()); quote.setTcvMinor(breakdown.tcvMinor()); + quote.setRenewalAnnualMinor(breakdown.renewalAnnualNetMinor()); quote.setLineItemsJson(writeLineItems(breakdown)); quote.setValidUntil(LocalDate.now().plusDays(30)); quote = quoteRepo.save(quote); @@ -275,7 +304,7 @@ public class ProcurementService { q != null && q.isIndemnification(), q != null && q.isTraining(), q != null && q.isQbr(), - q != null && q.isOfflineLicense(), + "airgap".equalsIgnoreCase(deployment), // offline .lic = air-gapped deploy deal.getDealId(), deal.getSubscriptionId()); return licenses.issueAnnualLicense( @@ -287,30 +316,26 @@ public class ProcurementService { } /** - * Check out the offline/air-gapped licence file for a team, when the offline add-on was - * purchased. Requires an issued licence on the deal and the accepted quote to carry the offline - * add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence - * exists). The certificate is generated on demand by Keygen and never stored. + * Check out the offline/air-gapped licence file (.lic) for a team. Available for an air-gapped + * deployment (chosen at trial setup) from the trial licence onward — cloud/self-hosted verify + * online against Keygen and don't get a file. Returns empty when there's no licence yet or the + * deployment isn't air-gapped, so the controller can 404 rather than leak that a licence + * exists. The certificate is generated on demand by Keygen (from whatever licence the deal + * currently holds — trial or committed annual) and never stored. + * + *

By design a team can self-select air-gapped at trial and download a real signed .lic + * before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so + * the file the verifier accepts self-expires at trial end. The buyer must re-download after + * provisioning to get the committed-term file (the portal warns about this). */ @Transactional(readOnly = true) public Optional offlineLicenseFile(Long teamId) { ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null); if (deal == null || deal.getLicenseRef() == null) return Optional.empty(); - if (!hasOfflineAddOn(deal)) return Optional.empty(); + if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty(); return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef())); } - /** - * Whether the deal's accepted quote carries the paid offline-licence add-on. Gated on - * the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft - * can't unlock the offline file — it's only available once the add-on has actually been bought. - */ - private boolean hasOfflineAddOn(ProcurementDeal deal) { - if (deal.getAcceptedQuoteId() == null) return false; - ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null); - return quote != null && quote.isOfflineLicense(); - } - /** * Reset a team's procurement: delete the deal (quotes + activity cascade). For * re-demos/testing. diff --git a/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java b/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java new file mode 100644 index 0000000000..b25444af40 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/usage/SaasFleetUsageController.java @@ -0,0 +1,103 @@ +package stirling.software.saas.usage; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * SaaS counterpart to {@code FleetUsageController}: the same "Free PDF Editors" figures, scoped to + * the caller's team (one SaaS backend serves many tenants, so the self-hosted server-wide variant + * is disabled here via {@code @Profile("!saas")}). + * + *

+ * + *

Team resolution + membership mirror {@code PaygWalletController}. Audit-derived figures are + * null (rendered "N/A") when EE auditing is below STANDARD. Cost is always $0 (client literal). + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/usage") +@Profile("saas") +@RequiredArgsConstructor +public class SaasFleetUsageController { + + private static final List PDF_TYPES = List.of("PDF_PROCESS", "FILE_OPERATION"); + + private final UserRepository userRepository; + private final TeamMembershipRepository memberRepo; + private final PersistentAuditEventRepository auditRepository; + private final AuditConfigurationProperties auditConfig; + + @GetMapping("/fleet-stats") + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = true) + public ResponseEntity fleetStats(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + List primary = memberRepo.findPrimaryMembership(user.getId()); + if (primary.isEmpty()) { + // Authenticated caller without a team — shouldn't happen post-migration; report an + // empty fleet rather than 500. + return ResponseEntity.ok(new FleetUsageStats(0L, null, null)); + } + Long teamId = primary.get(0).getTeam().getId(); + + List members = + memberRepo.findByTeamId(teamId).stream() + .map(m -> m.getUser().getUsername()) + .toList(); + Long deployed = (long) members.size(); + + // Guard the empty IN-list (invalid JPQL) as well as the audit-level gate. + boolean auditOn = !members.isEmpty() && auditConfig.isLevelEnabled(AuditLevel.STANDARD); + Instant since = Instant.now().minus(30, ChronoUnit.DAYS); + Long active = + auditOn + ? auditRepository + .countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + "WEB", "UI_DATA", members, since) + : null; + Long pdfs = + auditOn + ? auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + PDF_TYPES, "WEB", members, Instant.EPOCH) + : null; + if (active != null && active > deployed) { + active = deployed; // active editors are a subset of those deployed + } + return ResponseEntity.ok(new FleetUsageStats(deployed, active, pdfs)); + } +} diff --git a/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql new file mode 100644 index 0000000000..946b8415c6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V33__procurement_invoice_pdf.sql @@ -0,0 +1,7 @@ +-- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept +-- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead +-- of relying on the transient accept response. Written by the accept edge function via the +-- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT; diff --git a/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql new file mode 100644 index 0000000000..186e180cb2 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V34__procurement_deal_setup.sql @@ -0,0 +1,10 @@ +-- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows +-- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real +-- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 = +-- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it. + +ALTER TABLE stirling_pdf.procurement_deal + ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud'; + +ALTER TABLE stirling_pdf.procurement_deal + ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql new file mode 100644 index 0000000000..75a4479c31 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V35__procurement_renewal.sql @@ -0,0 +1,7 @@ +-- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the +-- figure shown to the buyer is locked to what they were quoted rather than recomputed from the +-- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a +-- Supabase twin migration mirrors it. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0; diff --git a/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql new file mode 100644 index 0000000000..7c213cb3dc --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V36__procurement_size_mult.sql @@ -0,0 +1,7 @@ +-- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer +-- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted +-- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows +-- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0; diff --git a/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql new file mode 100644 index 0000000000..3f07f201bb --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V37__procurement_quote_billing_details.sql @@ -0,0 +1,17 @@ +-- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a +-- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote +-- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge +-- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id +-- as custom fields). Country and currency are intentionally out of scope for now. Written and read +-- by the Java backend via JPA. A Supabase twin mirrors these columns. + +ALTER TABLE stirling_pdf.procurement_quote + ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), + ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255), + ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255), + ADD COLUMN IF NOT EXISTS city VARCHAR(128), + ADD COLUMN IF NOT EXISTS region VARCHAR(128), + ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32), + ADD COLUMN IF NOT EXISTS po_number VARCHAR(128), + ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64); diff --git a/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql new file mode 100644 index 0000000000..bc1a4e0f16 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V38__payg_run_grouping_doc_count.sql @@ -0,0 +1,39 @@ +-- PAYG size-scaled billing: run-scoped grouping + per-input-file counting. +-- +-- Two independent axes now live on a charge: +-- * doc_units — billing quantity, scales with file size (existing column) +-- * doc_count — number of INPUT files (the "unique PDFs" dimension); a split (1→many) stays 1, +-- a merge (N→1) is N. Fixed at open; joined steps never change it. +-- Plus run_id, the automation-run correlation id used to group a run's tool sub-steps into one +-- charge (replacing the old content+time-window grouping) and to keep two separate runs distinct. +-- +-- Everything is additive; no existing rows are modified, no columns dropped. + +-- ── processing_job ─────────────────────────────────────────────────────────── +ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS run_id VARCHAR(64); +ALTER TABLE processing_job ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; + +COMMENT ON COLUMN processing_job.run_id IS + 'Automation-run correlation id (X-Stirling-Run-Id); NULL for a standalone tool call. Lineage ' + 'joins are scoped to one run_id, so separate runs never merge even on identical bytes.'; +COMMENT ON COLUMN processing_job.doc_count IS + 'Number of input files this charge represents (the count dimension, distinct from size-scaled ' + 'doc_units). Split=1, merge=N. Fixed at open.'; + +-- ── wallet_ledger ──────────────────────────────────────────────────────────── +-- Denormalise the count dimension + input fingerprint onto the DEBIT row so usage analytics +-- (unique PDFs, per-category counts, size-multiplier average) query one table and survive +-- processing_job pruning. +ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS doc_count INTEGER NOT NULL DEFAULT 1; +ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS document_fingerprint VARCHAR(64); + +COMMENT ON COLUMN wallet_ledger.doc_count IS + 'Input-file count for this entry (mirrors processing_job.doc_count); summed for "PDFs processed".'; +COMMENT ON COLUMN wallet_ledger.document_fingerprint IS + 'SHA-256 of the entry''s input file set; COUNT(DISTINCT ...) gives unique PDFs. NULL for ' + 'aggregate/system entries (e.g. linked-instance sync).'; + +-- Distinct-PDF + size-multiplier queries scan by team + period. +CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_period_fp + ON wallet_ledger (team_id, occurred_at, document_fingerprint) + WHERE document_fingerprint IS NOT NULL; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 0e02c0ce50..1625116af0 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -127,9 +127,11 @@ class PaygWalletControllerTest { } private void stubEmptyLedgerReads(long teamId) { - when(ledgerRepo.sumPeriodAmountByCategory( + when(ledgerRepo.sumPeriodByCategoryWithDocs( eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) .thenReturn(List.of()); + when(ledgerRepo.periodUsageAnalytics(eq(teamId), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn(List.of(new Object[] {0L, 0L, 0L})); when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of()); } @@ -187,12 +189,17 @@ class PaygWalletControllerTest { when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L); when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L)); when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L)); - when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) + // [category, units, docs]: units scale with size, docs = input-file count. + when(ledgerRepo.sumPeriodByCategoryWithDocs( + eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) .thenReturn( List.of( - new Object[] {BillingCategory.API, 110L}, - new Object[] {BillingCategory.AI, 200L}, - new Object[] {BillingCategory.AUTOMATION, 2L})); + new Object[] {BillingCategory.API, 110L, 90L}, + new Object[] {BillingCategory.AI, 200L, 50L}, + new Object[] {BillingCategory.AUTOMATION, 2L, 2L})); + // [docsProcessed, uniquePdfs, sizeMultiplierPdfs] — single-row aggregate as List + when(ledgerRepo.periodUsageAnalytics(eq(99L), eq(LedgerEntryType.DEBIT), any(), any())) + .thenReturn(List.of(new Object[] {142L, 120L, 30L})); when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of()); ResponseEntity resp = @@ -214,6 +221,13 @@ class PaygWalletControllerTest { assertThat(body.categoryBreakdown().api()).isEqualTo(110); assertThat(body.categoryBreakdown().ai()).isEqualTo(200); assertThat(body.categoryBreakdown().automation()).isEqualTo(2); + // Count dimension is surfaced separately from the size-scaled units. + assertThat(body.categoryDocs().api()).isEqualTo(90); + assertThat(body.categoryDocs().ai()).isEqualTo(50); + assertThat(body.categoryDocs().automation()).isEqualTo(2); + assertThat(body.docsProcessedThisPeriod()).isEqualTo(142); + assertThat(body.uniquePdfsThisPeriod()).isEqualTo(120); + assertThat(body.sizeMultiplierPdfsThisPeriod()).isEqualTo(30); assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99"); // Member role → ledger never queried per-user. verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any()); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java index 65d55ccb86..e8547de268 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java @@ -35,7 +35,11 @@ class WalletSnapshotResponseTest { /* spendUnitsThisPeriod= */ 12, new CategoryBreakdown(5, 4, 3), List.of(new MemberRow("u1", "Ann", "ann@example.com", 8)), - List.of(new ActivityRow(1L, "api", "API usage", "2026-06-02T10:00", 4))); + List.of(new ActivityRow(1L, "api", "API usage", "2026-06-02T10:00", 4)), + /* categoryDocs= */ new CategoryBreakdown(3, 2, 1), + /* docsProcessedThisPeriod= */ 6, + /* uniquePdfsThisPeriod= */ 5, + /* sizeMultiplierPdfsThisPeriod= */ 2); } @Test @@ -107,7 +111,11 @@ class WalletSnapshotResponseTest { 0, new CategoryBreakdown(0, 0, 0), List.of(), - List.of()); + List.of(), + new CategoryBreakdown(0, 0, 0), + 0, + 0, + 0); assertThat(free.billableLimit()).isNull(); assertThat(free.pricePerDocMinor()).isNull(); diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index 128c336c32..be402dd061 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -434,6 +434,52 @@ class PaygChargeInterceptorTest { .isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE); } + @Test + void preHandle_runId_honouredOnlyWithAutomationHeader() throws Exception { + // An internal dispatch carries BOTH X-Stirling-Automation and X-Stirling-Run-Id, so the + // run id flows onto the ChargeContext (sub-steps of one run group into a single charge). + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Automation", "true"); + req.addHeader("X-Stirling-Run-Id", "run-abc"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().runId()).isEqualTo("run-abc"); + } + + @Test + void preHandle_runIdWithoutAutomationHeader_isIgnored() throws Exception { + // A raw external API call that sets X-Stirling-Run-Id on its own must NOT be able to group + // charges — the run id is dropped so each call stays its own charge ("charge per API + // call"). + authenticateWithApiKey(makeUser(7L, 42L)); + UUID jobId = UUID.randomUUID(); + when(chargeService.openProcess(any(), anyList())) + .thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED)); + org.mockito.ArgumentCaptor ctxCaptor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.saas.payg.charge.ChargeContext.class); + + MockMultipartHttpServletRequest req = newMultipart(); + req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes())); + req.addHeader("X-Stirling-Run-Id", "run-spoofed"); + + interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController()); + + verify(chargeService).openProcess(ctxCaptor.capture(), anyList()); + assertThat(ctxCaptor.getValue().runId()).isNull(); + } + // --- BillingCategory categorisation + bypass fast-path ------------------------------------- @Test diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java index ca7033405a..880a52da54 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/JobServiceTest.java @@ -183,6 +183,40 @@ class JobServiceTest { assertThat(detector.recorded(result.job().getId(), input, ArtifactKind.INPUT)).isTrue(); } + @Test + void joinOrOpen_nullRunId_neverJoins(@TempDir Path tmp) throws IOException { + // A standalone call (no run id) opens its own charge even when content matches an open + // job — run-scoped grouping only joins within the same automation run. + UUID existingId = UUID.randomUUID(); + Path input = givenFile(tmp, "in.bin"); + detector.willMatch( + input, new LineageMatch(existingId, ArtifactKind.INPUT, LocalDateTime.now())); + + JobContext standalone = + new JobContext( + 42L, 100L, JobSource.API, ProcessType.SINGLE_TOOL, 1L, 10); // null runId + JoinOrOpenResult result = service.joinOrOpen(standalone, List.of(input)); + + assertThat(result.disposition()).isEqualTo(JoinOrOpenResult.Disposition.OPENED); + verify(jobRepo, never()).findById(existingId); + } + + @Test + void joinOrOpen_opened_setsRunIdAndDocCountAndFingerprint(@TempDir Path tmp) + throws IOException { + // Two input files, no match → one fresh charge with doc_count = input-file count (2), + // the run id stamped, and a non-null fingerprint (used for unique-PDF counting). + Path a = givenFile(tmp, "a.bin"); + Path b = givenFile(tmp, "b.bin"); + + JoinOrOpenResult result = service.joinOrOpen(ctx(42L, 100L, 10), List.of(a, b)); + + assertThat(result.disposition()).isEqualTo(JoinOrOpenResult.Disposition.OPENED); + assertThat(result.job().getDocCount()).isEqualTo(2); + assertThat(result.job().getRunId()).isEqualTo("test-run"); + assertThat(result.job().getDocumentFingerprint()).isNotNull(); + } + @Test void joinOrOpen_emptyInputs_throws() { assertThatThrownBy(() -> service.joinOrOpen(ctx(42L, 100L, 10), List.of())) @@ -297,8 +331,10 @@ class JobServiceTest { // --- helpers -------------------------------------------------------------------------------- private static JobContext ctx(long userId, long teamId, int stepLimit) { + // Lineage joins are scoped to an automation run, so the join scenarios below run inside a + // run id. The standalone (null run id) path is covered by joinOrOpen_nullRunId_neverJoins. return new JobContext( - userId, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, stepLimit); + userId, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, stepLimit, "test-run"); } private static ProcessingJob openJob( diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java index 1676541db2..e62c6ff00b 100644 --- a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java @@ -18,8 +18,13 @@ class ProcurementPricingServiceTest { private static QuoteConfig cfg( long volume, int intensity, String deployment, int term, String sla) { + return cfgSize(volume, intensity, deployment, term, sla, 1.0); + } + + private static QuoteConfig cfgSize( + long volume, int intensity, String deployment, int term, String sla, double sizeMult) { return new QuoteConfig( - volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD"); + volume, 0, intensity, sizeMult, deployment, term, sla, false, false, false, "USD"); } @Test @@ -30,6 +35,7 @@ class ProcurementPricingServiceTest { assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000 assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000 + assertThat(q.renewalAnnualNetMinor()).isEqualTo(180_456_000L); // $1,752,000 + 3% CPI assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K } @@ -46,6 +52,41 @@ class ProcurementPricingServiceTest { assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support")); } + @Test + void renewalAppliesCpiEscalatorAfterAFlatTerm() { + // The committed term is flat (TCV = annual × years, asserted above). The 3% CPI escalator + // describes only the first post-term renewal: annual + one 3% step. It never touches TCV. + QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")); + assertThat(q.renewalAnnualNetMinor()) + .isEqualTo(Math.round(q.annualNetMinor() * 1.03)); // 16,527,800 → 17,023,634 + assertThat(q.tcvMinor()).isEqualTo(q.annualNetMinor() * 3); // renewal is outside the TCV + assertThat(pricing.cpiRatePct()).isEqualTo(3); + assertThat(pricing.renewalAnnualMinor(q.annualNetMinor())) + .isEqualTo(q.renewalAnnualNetMinor()); // stored-quote echo agrees with pricing + } + + @Test + void fileSizeTierMultipliesTheMeter() { + // D93: the size tier scales the per-run rate, so the meter (hence annual/TCV/renewal) grows + // while flat fees stay put. Compact (1.0) is the anchor; Standard is ×1.4, Heavy ×2.4. + long compact = + pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.0)).annualNetMinor(); + long standard = + pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.4)).annualNetMinor(); + long heavy = + pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 2.4)).annualNetMinor(); + + assertThat(compact).isEqualTo(16_527_800L); // == the Northwind anchor (size 1.0) + assertThat(standard).isEqualTo(23_138_900L); // rate ×1.4 + assertThat(compact).isLessThan(standard); + assertThat(standard).isLessThan(heavy); + // An unknown/tampered multiplier snaps back to 1.0 (no cheaper factor sneaks through). + assertThat( + pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 0.3)) + .annualNetMinor()) + .isEqualTo(compact); + } + @Test void rateFloorsAtHalfACent() { // 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to @@ -104,7 +145,7 @@ class ProcurementPricingServiceTest { long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); QuoteConfig c = new QuoteConfig( - 6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD"); + 6_000_000, 0, 4, 1.0, "cloud", 3, "standard", true, false, false, "USD"); QuoteBreakdown q = pricing.price(c); assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05)); } @@ -113,7 +154,7 @@ class ProcurementPricingServiceTest { void trainingIsOneTimeOutsideTheAnnual() { QuoteConfig withTraining = new QuoteConfig( - 6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD"); + 6_000_000, 0, 4, 1.0, "cloud", 3, "standard", false, true, false, "USD"); QuoteBreakdown q = pricing.price(withTraining); long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor(); diff --git a/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java b/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java new file mode 100644 index 0000000000..edd84e252a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/usage/SaasFleetUsageControllerTest.java @@ -0,0 +1,168 @@ +package stirling.software.saas.usage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.model.api.usage.FleetUsageStats; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class SaasFleetUsageControllerTest { + + @Mock private UserRepository userRepository; + @Mock private TeamMembershipRepository memberRepo; + @Mock private PersistentAuditEventRepository auditRepository; + @Mock private AuditConfigurationProperties auditConfig; + + private SaasFleetUsageController controller; + + @BeforeEach + void setUp() { + controller = + new SaasFleetUsageController( + userRepository, memberRepo, auditRepository, auditConfig); + } + + /** An Authentication whose principal is a User (AuthenticationUtils returns it directly). */ + private Authentication authFor(long userId) { + User user = mock(User.class); + when(user.getId()).thenReturn(userId); + Authentication auth = mock(Authentication.class); + when(auth.getPrincipal()).thenReturn(user); + return auth; + } + + private TeamMembership memberOf(long teamId, String username) { + // lenient: a member used only in the roster has its team.getId() stub unused, which strict + // stubbing would otherwise flag. + Team team = mock(Team.class); + lenient().when(team.getId()).thenReturn(teamId); + User u = mock(User.class); + lenient().when(u.getUsername()).thenReturn(username); + TeamMembership m = mock(TeamMembership.class); + lenient().when(m.getTeam()).thenReturn(team); + lenient().when(m.getUser()).thenReturn(u); + return m; + } + + @Test + @DisplayName("figures are scoped to the caller's team members") + void teamScopedFigures() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + TeamMembership bob = memberOf(42L, "bob@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader, bob)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class))) + .thenReturn(1L); + when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), eq("WEB"), anyList(), any(Instant.class))) + .thenReturn(88L); + + ResponseEntity res = controller.fleetStats(auth); + FleetUsageStats stats = res.getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(2L); + assertThat(stats.activeThisMonth()).isEqualTo(1L); + assertThat(stats.pdfsProcessed()).isEqualTo(88L); + verify(auditRepository) + .countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + eq(List.of("PDF_PROCESS", "FILE_OPERATION")), + eq("WEB"), + eq(List.of("leader@acme.test", "bob@acme.test")), + any(Instant.class)); + } + + @Test + @DisplayName("audit-derived figures are null when auditing is below STANDARD") + void auditOffYieldsNulls() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(false); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(1L); + assertThat(stats.activeThisMonth()).isNull(); + assertThat(stats.pdfsProcessed()).isNull(); + verify(auditRepository, never()) + .countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), any(), anyList(), any(Instant.class)); + } + + @Test + @DisplayName("active is clamped to deployed (a subset)") + void activeClampedToDeployed() { + Authentication auth = authFor(1L); + TeamMembership leader = memberOf(42L, "leader@acme.test"); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of(leader)); + when(memberRepo.findByTeamId(42L)).thenReturn(List.of(leader)); + when(auditConfig.isLevelEnabled(AuditLevel.STANDARD)).thenReturn(true); + when(auditRepository.countDistinctPrincipalsBySourceExcludingTypeAndPrincipalInAfter( + eq("WEB"), eq("UI_DATA"), anyList(), any(Instant.class))) + .thenReturn(5L); + when(auditRepository.countByTypeInAndSourceAndPrincipalInAndTimestampAfter( + anyList(), eq("WEB"), anyList(), any(Instant.class))) + .thenReturn(10L); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.activeThisMonth()).isEqualTo(1L); + } + + @Test + @DisplayName("a caller with no team gets an empty fleet, not a 500") + void noTeamReturnsEmpty() { + Authentication auth = authFor(1L); + when(memberRepo.findPrimaryMembership(1L)).thenReturn(List.of()); + + FleetUsageStats stats = controller.fleetStats(auth).getBody(); + + assertThat(stats).isNotNull(); + assertThat(stats.editorsDeployed()).isEqualTo(0L); + assertThat(stats.activeThisMonth()).isNull(); + assertThat(stats.pdfsProcessed()).isNull(); + } + + @Test + @DisplayName("an unauthenticated request is 401") + void unauthenticatedIs401() { + ResponseEntity res = controller.fleetStats(null); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } +} diff --git a/engine/src/stirling/documents/sqlite_vec_store.py b/engine/src/stirling/documents/sqlite_vec_store.py index 9346623314..5f909a4bcd 100644 --- a/engine/src/stirling/documents/sqlite_vec_store.py +++ b/engine/src/stirling/documents/sqlite_vec_store.py @@ -15,6 +15,10 @@ from stirling.documents.store import Document, DocumentStore, SearchResult, Stor from stirling.models import OwnerId, PrincipalId _READ_PERMISSION = "read" +# SQLite defaults to failing immediately (busy_timeout=0) when it can't grab the +# write lock. With multiple worker processes opening the same file, they collide on +# startup schema-init and get "database is locked". Wait for the lock instead. +_BUSY_TIMEOUT_MS = 5000 # sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS`` # so lexicographic comparison against ``datetime('now')`` matches chronological order. _SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S" @@ -52,6 +56,8 @@ class SqliteVecStore(DocumentStore): # Required so cascade deletes from documents_meta clean up child tables. conn.execute("PRAGMA foreign_keys=ON") if self._db_path is not None: + # Set before the WAL switch below: that pragma also takes the lock. + conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") conn.execute("PRAGMA journal_mode=WAL") self._conn = conn diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index ee182fe99c..0314c59fa4 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -10,3 +10,12 @@ VITE_USERBACK_TOKEN= # Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time). VITE_DEV_BYPASS_AUTH=false + +# Login landing mode - soft-release flag for the processor (portal): +# dynamic = role-based landing: team leads -> processor, members -> editor, +# with a per-user override in Settings > General. Default. +# editor = every user lands on the editor after login. The processor stays +# reachable via the app switcher, but no one is auto-routed to it and +# the per-user landing setting is hidden. Soft-release escape hatch. +# Set to `editor` (here or via the build env) to hold the processor back. +VITE_LOGIN_LANDING_MODE=dynamic diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 8f7e9c5921..287cd284d5 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "العقود" -"Financial reports" = "التقارير المالية" -"HR records" = "سجلات الموارد البشرية" -Insurance = "التأمين" -Invoices = "الفواتير" -"Legal filings" = "الإيداعات القانونية" -"Medical / PHI" = "طبي / PHI" -"Tax documents" = "المستندات الضريبية" - [policies.enforcement] applying = "جارٍ تطبيق {{names}}" applyingProgress = "جارٍ تطبيق {{names}} ({{done}} من {{total}})" @@ -6025,51 +6015,6 @@ notify = "إشعار عند التوجيه" onViolation = "عند عدم الامتثال" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "سنة واحدة" -"30 days" = "30 يومًا" -"90 days" = "90 يومًا" -Never = "أبدًا" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "وضع علامة للمراجعة" -Hold = "احتجاز" -"Route to bucket" = "توجيه إلى bucket" - -[policies.fieldOption.destination] -Documents = "المستندات" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "سنة واحدة" -"3 years" = "3 سنوات" -"30 days" = "30 يومًا" -"7 years" = "7 سنوات" -Indefinite = "إلى أجل غير مسمى" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "تنقيح PHI تلقائيًا" -"Block export" = "حظر التصدير" -"Flag for review" = "وضع علامة للمراجعة" -"Quarantine document" = "عزل المستند" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "إغلاق التراكب" enforcingTitle = "جارٍ فرض السياسة..." viewAnyway = "عرض الملف (لا تزال السياسة قيد الفرض)" +[policyOption] +autoRedactPhi = "تنقيح PHI تلقائيًا" +blockExport = "حظر التصدير" +contracts = "العقود" +documents = "المستندات" +fedramp = "FedRAMP" +financialReports = "التقارير المالية" +flagForReview = "وضع علامة للمراجعة" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "احتجاز" +hrRecords = "سجلات الموارد البشرية" +indefinite = "إلى أجل غير مسمى" +insurance = "التأمين" +invoices = "الفواتير" +iso27001 = "ISO 27001" +legalFilings = "الإيداعات القانونية" +medicalPhi = "طبي / PHI" +never = "أبدًا" +ninetyDays = "90 يومًا" +oneYear = "سنة واحدة" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "عزل المستند" +routeToBucket = "توجيه إلى bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 سنوات" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "المستندات الضريبية" +thirtyDays = "30 يومًا" +threeYears = "3 سنوات" +webhook = "Webhook" + [portal] comingSoon = "قريبًا" @@ -8067,10 +8050,6 @@ title = "انتهت صلاحية الجلسة" [portal.users] title = "المستخدمون" -[portal.users.roles] -subtitle = "كل دور موجود في كل خطة — وما يمكن لكل دور فعله ثابت عبر المؤسسة." -title = "الأدوار" - [portal.users.roles.admin] label = "المسؤول (مالك المؤسسة)" summary = "حوكمة كاملة على مساحة العمل والإعدادات والأعضاء." diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f0dea4966e..f27f1ff0d1 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Müqavilələr" -"Financial reports" = "Maliyyə hesabatları" -"HR records" = "HR qeydləri" -Insurance = "Sığorta" -Invoices = "Fakturalar" -"Legal filings" = "Hüquqi sənədlər" -"Medical / PHI" = "Tibbi / PHI" -"Tax documents" = "Vergi sənədləri" - [policies.enforcement] applying = "{{names}} tətbiq edilir" applyingProgress = "{{names}} tətbiq edilir ({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "Yönləndirmədə bildir" onViolation = "Uyğunsuzluq olduqda" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 il" -"30 days" = "30 gün" -"90 days" = "90 gün" -Never = "Heç vaxt" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Baxış üçün işarələ" -Hold = "Saxla" -"Route to bucket" = "Bucket-ə yönləndir" - -[policies.fieldOption.destination] -Documents = "Sənədlər" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 il" -"3 years" = "3 il" -"30 days" = "30 gün" -"7 years" = "7 il" -Indefinite = "Qeyri-müəyyən" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI-ni avtomatik redaktə et" -"Block export" = "Eksportu blokla" -"Flag for review" = "Baxış üçün işarələ" -"Quarantine document" = "Sənədi karantinə al" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Örtüyü bağla" enforcingTitle = "Siyasət tətbiq edilir..." viewAnyway = "Faylı göstər (siyasət hələ tətbiq edilir)" +[policyOption] +autoRedactPhi = "PHI-ni avtomatik redaktə et" +blockExport = "Eksportu blokla" +contracts = "Müqavilələr" +documents = "Sənədlər" +fedramp = "FedRAMP" +financialReports = "Maliyyə hesabatları" +flagForReview = "Baxış üçün işarələ" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Saxla" +hrRecords = "HR qeydləri" +indefinite = "Qeyri-müəyyən" +insurance = "Sığorta" +invoices = "Fakturalar" +iso27001 = "ISO 27001" +legalFilings = "Hüquqi sənədlər" +medicalPhi = "Tibbi / PHI" +never = "Heç vaxt" +ninetyDays = "90 gün" +oneYear = "1 il" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Sənədi karantinə al" +routeToBucket = "Bucket-ə yönləndir" +s3Bucket = "S3 bucket" +sevenYears = "7 il" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Vergi sənədləri" +thirtyDays = "30 gün" +threeYears = "3 il" +webhook = "Webhook" + [portal] comingSoon = "Tezliklə" @@ -8067,10 +8050,6 @@ title = "Sessiyanın müddəti bitib" [portal.users] title = "İstifadəçilər" -[portal.users.roles] -subtitle = "Hər rol hər planda mövcuddur — hər birinin nə edə biləcəyi təşkilat üzrə sabitdir." -title = "Rollar" - [portal.users.roles.admin] label = "Admin (Təşkilat sahibi)" summary = "İş sahəsi, parametrlər və üzvlər üzərində tam idarəetmə." diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index 401c100a34..b967c4b7c8 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Договори" -"Financial reports" = "Финансови отчети" -"HR records" = "HR записи" -Insurance = "Застраховане" -Invoices = "Фактури" -"Legal filings" = "Правни документи" -"Medical / PHI" = "Медицински / PHI" -"Tax documents" = "Данъчни документи" - [policies.enforcement] applying = "Прилагане на {{names}}" applyingProgress = "Прилагане на {{names}} ({{done}} от {{total}})" @@ -6025,51 +6015,6 @@ notify = "Уведоми при маршрутизиране" onViolation = "При несъответствие" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 година" -"30 days" = "30 дни" -"90 days" = "90 дни" -Never = "Никога" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Маркирай за преглед" -Hold = "Задръж" -"Route to bucket" = "Насочи към bucket" - -[policies.fieldOption.destination] -Documents = "Документи" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 година" -"3 years" = "3 години" -"30 days" = "30 дни" -"7 years" = "7 години" -Indefinite = "Безсрочно" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Автоматично заличаване на PHI" -"Block export" = "Блокирай експортирането" -"Flag for review" = "Маркирай за преглед" -"Quarantine document" = "Постави документа под карантина" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Скрий наслагването" enforcingTitle = "Прилагане на политика..." viewAnyway = "Виж файла (политиката все още се прилага)" +[policyOption] +autoRedactPhi = "Автоматично заличаване на PHI" +blockExport = "Блокирай експортирането" +contracts = "Договори" +documents = "Документи" +fedramp = "FedRAMP" +financialReports = "Финансови отчети" +flagForReview = "Маркирай за преглед" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Задръж" +hrRecords = "HR записи" +indefinite = "Безсрочно" +insurance = "Застраховане" +invoices = "Фактури" +iso27001 = "ISO 27001" +legalFilings = "Правни документи" +medicalPhi = "Медицински / PHI" +never = "Никога" +ninetyDays = "90 дни" +oneYear = "1 година" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Постави документа под карантина" +routeToBucket = "Насочи към bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 години" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Данъчни документи" +thirtyDays = "30 дни" +threeYears = "3 години" +webhook = "Webhook" + [portal] comingSoon = "Очаквайте скоро" @@ -8067,10 +8050,6 @@ title = "Сесията е изтекла" [portal.users] title = "Потребители" -[portal.users.roles] -subtitle = "Всяка роля съществува във всеки план — това, което всяка може да прави, е фиксирано за цялата организация." -title = "Роли" - [portal.users.roles.admin] label = "Администратор (собственик на организация)" summary = "Пълно управление на работното пространство, настройките и членовете." diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 3b386d0328..7f748e20b2 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contractes" -"Financial reports" = "Informes financers" -"HR records" = "Registres de RR. HH." -Insurance = "Assegurances" -Invoices = "Factures" -"Legal filings" = "Presentacions legals" -"Medical / PHI" = "Mèdic / PHI" -"Tax documents" = "Documents fiscals" - [policies.enforcement] applying = "Aplicant {{names}}" applyingProgress = "Aplicant {{names}} ({{done}} de {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notifica en encaminar" onViolation = "Quan no compleix" webhookUrl = "URL del webhook" -[policies.fieldOption.archiveAfter] -"1 year" = "1 any" -"30 days" = "30 dies" -"90 days" = "90 dies" -Never = "Mai" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marca per revisar" -Hold = "Retén" -"Route to bucket" = "Encamina al bucket" - -[policies.fieldOption.destination] -Documents = "Documents" -"S3 bucket" = "bucket S3" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 any" -"3 years" = "3 anys" -"30 days" = "30 dies" -"7 years" = "7 anys" -Indefinite = "Indefinit" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Oculta automàticament PHI" -"Block export" = "Bloqueja l'exportació" -"Flag for review" = "Marca per revisar" -"Quarantine document" = "Posa el document en quarantena" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Descarta la superposició" enforcingTitle = "S'està aplicant la política..." viewAnyway = "Mostra el fitxer (la política encara s'està aplicant)" +[policyOption] +autoRedactPhi = "Oculta automàticament PHI" +blockExport = "Bloqueja l'exportació" +contracts = "Contractes" +documents = "Documents" +fedramp = "FedRAMP" +financialReports = "Informes financers" +flagForReview = "Marca per revisar" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Retén" +hrRecords = "Registres de RR. HH." +indefinite = "Indefinit" +insurance = "Assegurances" +invoices = "Factures" +iso27001 = "ISO 27001" +legalFilings = "Presentacions legals" +medicalPhi = "Mèdic / PHI" +never = "Mai" +ninetyDays = "90 dies" +oneYear = "1 any" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Posa el document en quarantena" +routeToBucket = "Encamina al bucket" +s3Bucket = "bucket S3" +sevenYears = "7 anys" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documents fiscals" +thirtyDays = "30 dies" +threeYears = "3 anys" +webhook = "Webhook" + [portal] comingSoon = "Properament" @@ -8067,10 +8050,6 @@ title = "Sessió caducada" [portal.users] title = "Usuaris" -[portal.users.roles] -subtitle = "Cada rol existeix en tots els plans — el que pot fer cadascun és fix a tota l'organització." -title = "Rols" - [portal.users.roles.admin] label = "Admin (propietari de l'org)" summary = "Governança completa sobre l'espai de treball, la configuració i els membres." diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 086d3af5ad..cd6f7c4315 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Smlouvy" -"Financial reports" = "Finanční zprávy" -"HR records" = "HR záznamy" -Insurance = "Pojištění" -Invoices = "Faktury" -"Legal filings" = "Právní podání" -"Medical / PHI" = "Zdravotní / PHI" -"Tax documents" = "Daňové dokumenty" - [policies.enforcement] applying = "Probíhá použití {{names}}" applyingProgress = "Probíhá použití {{names}} ({{done}} z {{total}})" @@ -6025,51 +6015,6 @@ notify = "Upozornit při směrování" onViolation = "Při nesouladu" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 rok" -"30 days" = "30 dní" -"90 days" = "90 dní" -Never = "Nikdy" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Označit ke kontrole" -Hold = "Zadržet" -"Route to bucket" = "Směrovat do bucketu" - -[policies.fieldOption.destination] -Documents = "Dokumenty" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 rok" -"3 years" = "3 roky" -"30 days" = "30 dní" -"7 years" = "7 let" -Indefinite = "Neurčitě" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automaticky redigovat PHI" -"Block export" = "Blokovat export" -"Flag for review" = "Označit ke kontrole" -"Quarantine document" = "Umístit dokument do karantény" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Zavřít překryvnou vrstvu" enforcingTitle = "Vynucování zásady..." viewAnyway = "Zobrazit soubor (zásada se stále vynucuje)" +[policyOption] +autoRedactPhi = "Automaticky redigovat PHI" +blockExport = "Blokovat export" +contracts = "Smlouvy" +documents = "Dokumenty" +fedramp = "FedRAMP" +financialReports = "Finanční zprávy" +flagForReview = "Označit ke kontrole" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Zadržet" +hrRecords = "HR záznamy" +indefinite = "Neurčitě" +insurance = "Pojištění" +invoices = "Faktury" +iso27001 = "ISO 27001" +legalFilings = "Právní podání" +medicalPhi = "Zdravotní / PHI" +never = "Nikdy" +ninetyDays = "90 dní" +oneYear = "1 rok" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Umístit dokument do karantény" +routeToBucket = "Směrovat do bucketu" +s3Bucket = "S3 bucket" +sevenYears = "7 let" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Daňové dokumenty" +thirtyDays = "30 dní" +threeYears = "3 roky" +webhook = "Webhook" + [portal] comingSoon = "Již brzy" @@ -8067,10 +8050,6 @@ title = "Relace vypršela" [portal.users] title = "Uživatelé" -[portal.users.roles] -subtitle = "Každá role existuje v každém plánu — co která může dělat, je pevně dané napříč organizací." -title = "Role" - [portal.users.roles.admin] label = "Admin (vlastník org.)" summary = "Plná správa governance nad workspace, nastavením a členy." diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index 6cc8988263..665eaeece6 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Kontrakter" -"Financial reports" = "Finansrapporter" -"HR records" = "HR-registreringer" -Insurance = "Forsikring" -Invoices = "Fakturaer" -"Legal filings" = "Juridiske indleveringer" -"Medical / PHI" = "Medicinsk / PHI" -"Tax documents" = "Skattedokumenter" - [policies.enforcement] applying = "Anvender {{names}}" applyingProgress = "Anvender {{names}} ({{done}} af {{total}})" @@ -6025,51 +6015,6 @@ notify = "Giv besked ved routing" onViolation = "Når ikke-kompatibel" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 år" -"30 days" = "30 dage" -"90 days" = "90 dage" -Never = "Aldrig" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Markér til gennemgang" -Hold = "Tilbagehold" -"Route to bucket" = "Route til bucket" - -[policies.fieldOption.destination] -Documents = "Dokumenter" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 år" -"3 years" = "3 år" -"30 days" = "30 dage" -"7 years" = "7 år" -Indefinite = "På ubestemt tid" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Auto-rediger PHI" -"Block export" = "Bloker eksport" -"Flag for review" = "Markér til gennemgang" -"Quarantine document" = "Sæt dokument i karantæne" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Afvis overlay" enforcingTitle = "Håndhæver politik..." viewAnyway = "Vis fil (politik håndhæves stadig)" +[policyOption] +autoRedactPhi = "Auto-rediger PHI" +blockExport = "Bloker eksport" +contracts = "Kontrakter" +documents = "Dokumenter" +fedramp = "FedRAMP" +financialReports = "Finansrapporter" +flagForReview = "Markér til gennemgang" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Tilbagehold" +hrRecords = "HR-registreringer" +indefinite = "På ubestemt tid" +insurance = "Forsikring" +invoices = "Fakturaer" +iso27001 = "ISO 27001" +legalFilings = "Juridiske indleveringer" +medicalPhi = "Medicinsk / PHI" +never = "Aldrig" +ninetyDays = "90 dage" +oneYear = "1 år" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Sæt dokument i karantæne" +routeToBucket = "Route til bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 år" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Skattedokumenter" +thirtyDays = "30 dage" +threeYears = "3 år" +webhook = "Webhook" + [portal] comingSoon = "Kommer snart" @@ -8067,10 +8050,6 @@ title = "Session udløbet" [portal.users] title = "Brugere" -[portal.users.roles] -subtitle = "Alle roller findes på alle abonnementer — hvad hver enkelt kan gøre, er fast på tværs af organisationen." -title = "Roller" - [portal.users.roles.admin] label = "Admin (organisationsejer)" summary = "Fuld styring over workspace, indstillinger og medlemmer." diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 0b4c2c0468..29efeca137 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -356,6 +356,7 @@ text = "Diese Berechtigungen kontrollieren, was Benutzer mit der PDF machen kön title = "Berechtigungen ändern" [addStamp] +preview = "Stempelvorschau" tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" [AddStampRequest] @@ -1842,6 +1843,15 @@ export = "Export" accessDenied = "Zugriff verweigert" insufficientPermissions = "Sie haben keine Berechtigung, diese Aktion auszuführen." +[auth.callback] +completing = "Authentifizierung wird abgeschlossen" +invalidToken = "OAuth-Anmeldung fehlgeschlagen – ungültiges Token." +missingToken = "OAuth-Anmeldung fehlgeschlagen – kein Token empfangen." +oauthFailed = "OAuth-Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." +pleaseWait = "Bitte warten Sie, während wir Ihre Anmeldung abschließen." +signedOut = "Sie wurden abgemeldet. Bitte melden Sie sich erneut an." +windowMayClose = "Sie können dieses Fenster schließen, sobald der Vorgang abgeschlossen ist." + [auth.displayName] guest = "Gast" user = "Benutzer" @@ -2824,6 +2834,7 @@ next = "Weiter" preview = "Vorschau" previous = "Zurück" refresh = "Aktualisieren" +remaining = "Verbleibend" retry = "Wiederholen" save = "Save" @@ -4072,10 +4083,17 @@ discord = "Discord" issues = "GitHub" [formFill] +allSaved = "Alles gespeichert" +extractCsvError = "CSV konnte nicht extrahiert werden" +extractXlsxError = "XLSX konnte nicht extrahiert werden" flattenAfterFilling = "Nach dem Ausfüllen ebnen" +requiredAbbreviation = "erf." +requiredFieldsError = "Bitte füllen Sie alle Pflichtfelder aus" rescanFields = "Felder erneut scannen" rescanFormFields = "Formularfelder erneut scannen" save = "Speichern" +saveShortcut = "Strg+S zum Speichern" +unsavedChanges = "Nicht gespeicherte Änderungen" [formFill.sidebar] close = "Seitenleiste schließen" @@ -4996,7 +5014,21 @@ titleWithOs = "Herunterladen für {{osLabel}}" [onboarding.mfa] authenticationCode = "Authentifizierungscode" +description = "Schützen Sie Ihr Konto, indem Sie eine Authenticator-App verknüpfen. Scannen Sie den QR-Code oder geben Sie den Einrichtungsschlüssel ein und bestätigen Sie anschließend den sechsstelligen Code." +enable = "MFA aktivieren" +enableError = "Die Multi-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfen Sie den Code und versuchen Sie es erneut." +enterCode = "Geben Sie den sechsstelligen Code aus Ihrer App ein." +enterCodeError = "Geben Sie den Authentifizierungscode ein, um fortzufahren." +logout = "Abmelden" +openAuthenticator = "Öffnen Sie Google Authenticator, Authy oder 1Password." qrCodeLoading = "Ihr QR-Code wird erstellt…" +regenerateQrCode = "QR-Code neu erstellen" +scanQrCode = "Scannen Sie den QR-Code oder geben Sie den unten stehenden Einrichtungsschlüssel ein." +setupError = "Die Einrichtung der Zwei-Faktor-Authentifizierung konnte nicht gestartet werden. Bitte versuchen Sie es erneut." +setupKey = "Einrichtungsschlüssel (manuelle Eingabe)" +stepByStep = "Schritt für Schritt" +success = "MFA wurde aktiviert. Sie können jetzt fortfahren." +title = "Einrichtung der Multi-Faktor-Authentifizierung" [onboarding.planOverview] adminBodyLoginDisabled = "Sobald Sie den Login-Modus aktivieren, können Sie Nutzer verwalten, Einstellungen konfigurieren und die Servergesundheit überwachen. Die ersten {{freeTierLimit}} Personen auf Ihrem Server nutzen Stirling kostenlos." @@ -5980,16 +6012,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Verträge" -"Financial reports" = "Finanzberichte" -"HR records" = "Personalakten" -Insurance = "Versicherung" -Invoices = "Rechnungen" -"Legal filings" = "Rechtliche Einreichungen" -"Medical / PHI" = "Medizinisch / PHI" -"Tax documents" = "Steuerdokumente" - [policies.enforcement] applying = "Wende {{names}} an" applyingProgress = "Wende {{names}} an ({{done}} von {{total}})" @@ -6025,51 +6047,6 @@ notify = "Bei Weiterleitung benachrichtigen" onViolation = "Bei Nichteinhaltung" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 Jahr" -"30 days" = "30 Tage" -"90 days" = "90 Tage" -Never = "Nie" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Zur Prüfung markieren" -Hold = "Zurückhalten" -"Route to bucket" = "In Bucket weiterleiten" - -[policies.fieldOption.destination] -Documents = "Dokumente" -"S3 bucket" = "S3-Bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 Jahr" -"3 years" = "3 Jahre" -"30 days" = "30 Tage" -"7 years" = "7 Jahre" -Indefinite = "Unbefristet" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI automatisch schwärzen" -"Block export" = "Export blockieren" -"Flag for review" = "Zur Prüfung markieren" -"Quarantine document" = "Dokument unter Quarantäne stellen" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6180,44 @@ dismiss = "Overlay ausblenden" enforcingTitle = "Richtlinie wird durchgesetzt..." viewAnyway = "Datei anzeigen (Richtlinie wird noch durchgesetzt)" +[policyOption] +autoRedactPhi = "PHI automatisch schwärzen" +blockExport = "Export blockieren" +contracts = "Verträge" +documents = "Dokumente" +fedramp = "FedRAMP" +financialReports = "Finanzberichte" +flagForReview = "Zur Prüfung markieren" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Zurückhalten" +hrRecords = "Personalakten" +indefinite = "Unbefristet" +insurance = "Versicherung" +invoices = "Rechnungen" +iso27001 = "ISO 27001" +legalFilings = "Rechtliche Einreichungen" +medicalPhi = "Medizinisch / PHI" +never = "Nie" +ninetyDays = "90 Tage" +oneYear = "1 Jahr" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Dokument unter Quarantäne stellen" +routeToBucket = "In Bucket weiterleiten" +s3Bucket = "S3-Bucket" +sevenYears = "7 Jahre" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Steuerdokumente" +thirtyDays = "30 Tage" +threeYears = "3 Jahre" +webhook = "Webhook" + [portal] comingSoon = "Demnächst" @@ -8067,10 +8082,6 @@ title = "Sitzung abgelaufen" [portal.users] title = "Benutzer" -[portal.users.roles] -subtitle = "Jede Rolle existiert in jedem Plan — was jede Rolle tun kann, ist in der gesamten Organisation festgelegt." -title = "Rollen" - [portal.users.roles.admin] label = "Admin (Org-Inhaber)" summary = "Vollständige Steuerung über Workspace, Einstellungen und Mitglieder." @@ -9131,6 +9142,11 @@ security = "Sicherheit" telegram = "Telegram" title = "Sicherheit & Authentifizierung" +[settings.signOut] +confirm = "Möchten Sie sich wirklich abmelden?" +submit = "Abmelden" +title = "Abmelden" + [settings.team] title = "Team" @@ -9747,6 +9763,9 @@ uploadButton = "Auf Server hochladen" title = "Stirling-PDF Survey" [swagger] +clickHere = "hier klicken" +fallback = "Falls die Seite nicht automatisch geöffnet wurde," +opening = "Swagger UI wird in einem neuen Tab geöffnet..." tags = "API,Dokumentation,Swagger,Endpunkte,Entwicklung" title = "API-Dokumentation" @@ -10302,9 +10321,11 @@ title = "Attachments" [viewer.bookmarks] bookmarkTitle = "Lesezeichentitel" +bookmarkTitleRequired = "Ein Lesezeichentitel ist erforderlich" closeSidebar = "Seitenleiste für Lesezeichen schließen" collapseAll = "Alle Lesezeichen einklappen" expandAll = "Alle Lesezeichen ausklappen" +searchPlaceholder = "Lesezeichen durchsuchen" [viewer.comments] addComment = "Kommentar hinzufügen" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 8c850d6223..677b255d82 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Συμβάσεις" -"Financial reports" = "Οικονομικές αναφορές" -"HR records" = "Αρχεία HR" -Insurance = "Ασφάλειες" -Invoices = "Τιμολόγια" -"Legal filings" = "Νομικές καταθέσεις" -"Medical / PHI" = "Ιατρικά / PHI" -"Tax documents" = "Φορολογικά έγγραφα" - [policies.enforcement] applying = "Εφαρμογή {{names}}" applyingProgress = "Εφαρμογή {{names}} ({{done}} από {{total}})" @@ -6025,51 +6015,6 @@ notify = "Ειδοποίηση κατά τη δρομολόγηση" onViolation = "Όταν δεν συμμορφώνεται" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 έτος" -"30 days" = "30 ημέρες" -"90 days" = "90 ημέρες" -Never = "Ποτέ" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Επισήμανση για έλεγχο" -Hold = "Διατήρηση" -"Route to bucket" = "Δρομολόγηση σε bucket" - -[policies.fieldOption.destination] -Documents = "Έγγραφα" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 έτος" -"3 years" = "3 έτη" -"30 days" = "30 ημέρες" -"7 years" = "7 έτη" -Indefinite = "Επ' αόριστον" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Αυτόματη απόκρυψη PHI" -"Block export" = "Αποκλεισμός εξαγωγής" -"Flag for review" = "Επισήμανση για έλεγχο" -"Quarantine document" = "Καραντίνα εγγράφου" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Απόρριψη επικάλυψης" enforcingTitle = "Επιβολή πολιτικής..." viewAnyway = "Προβολή αρχείου (η πολιτική εξακολουθεί να επιβάλλεται)" +[policyOption] +autoRedactPhi = "Αυτόματη απόκρυψη PHI" +blockExport = "Αποκλεισμός εξαγωγής" +contracts = "Συμβάσεις" +documents = "Έγγραφα" +fedramp = "FedRAMP" +financialReports = "Οικονομικές αναφορές" +flagForReview = "Επισήμανση για έλεγχο" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Διατήρηση" +hrRecords = "Αρχεία HR" +indefinite = "Επ' αόριστον" +insurance = "Ασφάλειες" +invoices = "Τιμολόγια" +iso27001 = "ISO 27001" +legalFilings = "Νομικές καταθέσεις" +medicalPhi = "Ιατρικά / PHI" +never = "Ποτέ" +ninetyDays = "90 ημέρες" +oneYear = "1 έτος" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Καραντίνα εγγράφου" +routeToBucket = "Δρομολόγηση σε bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 έτη" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Φορολογικά έγγραφα" +thirtyDays = "30 ημέρες" +threeYears = "3 έτη" +webhook = "Webhook" + [portal] comingSoon = "Προσεχώς" @@ -8067,10 +8050,6 @@ title = "Η συνεδρία έληξε" [portal.users] title = "Χρήστες" -[portal.users.roles] -subtitle = "Κάθε ρόλος υπάρχει σε κάθε πλάνο — τι μπορεί να κάνει ο καθένας είναι σταθερό σε όλο τον οργανισμό." -title = "Ρόλοι" - [portal.users.roles.admin] label = "Διαχειριστής (Κάτοχος οργανισμού)" summary = "Πλήρης διακυβέρνηση του χώρου εργασίας, των ρυθμίσεων και των μελών." diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 1e43a305e5..bdc49c4258 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contracts" -"Financial reports" = "Financial reports" -"HR records" = "HR records" -Insurance = "Insurance" -Invoices = "Invoices" -"Legal filings" = "Legal filings" -"Medical / PHI" = "Medical / PHI" -"Tax documents" = "Tax documents" - [policies.enforcement] applying = "Applying {{names}}" applyingProgress = "Applying {{names}} ({{done}} of {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notify on route" onViolation = "When non-compliant" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 year" -"30 days" = "30 days" -"90 days" = "90 days" -Never = "Never" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Flag for review" -Hold = "Hold" -"Route to bucket" = "Route to bucket" - -[policies.fieldOption.destination] -Documents = "Documents" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 year" -"3 years" = "3 years" -"30 days" = "30 days" -"7 years" = "7 years" -Indefinite = "Indefinite" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Auto-redact PHI" -"Block export" = "Block export" -"Flag for review" = "Flag for review" -"Quarantine document" = "Quarantine document" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Dismiss overlay" enforcingTitle = "Enforcing policy..." viewAnyway = "View file (policy still enforcing)" +[policyOption] +autoRedactPhi = "Auto-redact PHI" +blockExport = "Block export" +contracts = "Contracts" +documents = "Documents" +fedramp = "FedRAMP" +financialReports = "Financial reports" +flagForReview = "Flag for review" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Hold" +hrRecords = "HR records" +indefinite = "Indefinite" +insurance = "Insurance" +invoices = "Invoices" +iso27001 = "ISO 27001" +legalFilings = "Legal filings" +medicalPhi = "Medical / PHI" +never = "Never" +ninetyDays = "90 days" +oneYear = "1 year" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Quarantine document" +routeToBucket = "Route to bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 years" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Tax documents" +thirtyDays = "30 days" +threeYears = "3 years" +webhook = "Webhook" + [portal] comingSoon = "Coming soon" @@ -8067,10 +8050,6 @@ title = "Session expired" [portal.users] title = "Users" -[portal.users.roles] -subtitle = "Every role exists on every plan — what each one can do is fixed across the organisation." -title = "Roles" - [portal.users.roles.admin] label = "Admin (Org owner)" summary = "Full governance over the workspace, settings and members." diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index f3fe7bfefd..9663478000 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -356,6 +356,7 @@ text = "These permissions control what users can do with the PDF. Most effective title = "Change Permissions" [addStamp] +preview = "Preview Stamp" tags = "stamp,mark,seal,approved,rejected,confidential,stamp tool,rubber stamp,date stamp,approval stamp,received,void,copy,original" [AddStampRequest] @@ -1842,6 +1843,15 @@ export = "Export" accessDenied = "Access Denied" insufficientPermissions = "You do not have permission to perform this action." +[auth.callback] +completing = "Completing authentication" +invalidToken = "OAuth login failed - invalid token." +missingToken = "OAuth login failed - no token received." +oauthFailed = "OAuth login failed. Please try again." +pleaseWait = "Please wait while we finish signing you in." +signedOut = "You have been signed out. Please sign in again." +windowMayClose = "You can close this window once it completes." + [auth.displayName] guest = "Guest" user = "User" @@ -2824,6 +2834,7 @@ next = "Next" preview = "Preview" previous = "Previous" refresh = "Refresh" +remaining = "Remaining" retry = "Retry" save = "Save" @@ -4072,10 +4083,17 @@ discord = "Discord" issues = "GitHub" [formFill] +allSaved = "All saved" +extractCsvError = "Failed to extract CSV" +extractXlsxError = "Failed to extract XLSX" flattenAfterFilling = "Flatten after filling" +requiredAbbreviation = "req" +requiredFieldsError = "Please fill in all required fields" rescanFields = "Re-scan fields" rescanFormFields = "Re-scan form fields" save = "Save" +saveShortcut = "Ctrl+S to save" +unsavedChanges = "Unsaved changes" [formFill.sidebar] close = "Close sidebar" @@ -4647,7 +4665,6 @@ welcomeTitle = "You've been invited!" [landing] addFiles = "Add Files" -heroSubtitle = "Drop in or add an existing PDF to get started." mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" uploadFromComputer = "Upload from computer" @@ -4962,11 +4979,11 @@ text = "Decide how you want the text output formatted:" title = "Output" [onboarding] -activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process." +activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool." allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to." -fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." +fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." fileSources = "You can upload new files or access recent files from here. For the tour, we'll just use a sample file." @@ -4976,6 +4993,7 @@ pinButton = "You can use the Pin button if you'd rather your fi results = "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. " runButton = "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs." selectCropTool = "Let's select the Crop tool to demonstrate how to use one of the tools." +stepOf = "Step {{current}} of {{total}}" toolInterface = "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet." workbench = "This is the Workbench - the main area where you view and edit your PDFs." wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." @@ -4988,6 +5006,26 @@ showMeAround = "Show me around" skipForNow = "Skip for now" skipTheTour = "Skip the tour" +[onboarding.checklist] +dismiss = "Dismiss" +title = "Set up Stirling PDF" + +[onboarding.checklist.downloadDesktop] +description = "Run Stirling natively on your machine" +title = "Download Stirling for Desktop" + +[onboarding.checklist.inviteTeam] +description = "Collaborate with your team" +title = "Invite team members" + +[onboarding.checklist.shareAnalytics] +description = "Help improve Stirling" +title = "Share anonymous usage data" + +[onboarding.checklist.takeTour] +description = "See how Stirling works in a quick walkthrough" +title = "Take the tour" + [onboarding.desktopInstall] body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer." selectOs = "Select operating system" @@ -4996,7 +5034,21 @@ titleWithOs = "Download for {{osLabel}}" [onboarding.mfa] authenticationCode = "Authentication code" +description = "Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish." +enable = "Enable MFA" +enableError = "Unable to enable two-factor authentication. Check the code and try again." +enterCode = "Enter the 6-digit code from your app." +enterCodeError = "Enter the authentication code to continue." +logout = "Logout" +openAuthenticator = "Open Google Authenticator, Authy, or 1Password." qrCodeLoading = "Generating your QR code…" +regenerateQrCode = "Regenerate QR code" +scanQrCode = "Scan the QR code or enter the setup key below." +setupError = "Unable to start two-factor setup. Please try again." +setupKey = "Setup key (manual entry)" +stepByStep = "Step-by-step" +success = "MFA has been enabled. You can now continue." +title = "Multi-Factor Authentication Setup" [onboarding.planOverview] adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge." @@ -5005,6 +5057,11 @@ adminTitle = "Admin Overview" userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" +[onboarding.processorIntro] +body = "Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." +cta = "Check out the Processor" +title = "Check out the Stirling Processor" + [onboarding.saas.freeEditor] freeLine = "The editor is now completely free." premium = "We've added loads of new features, including Policies and Agent Chat." @@ -5937,59 +5994,12 @@ keyDescription = "Paste the license key from your email" success = "License Activated!" successMessage = "Your license has been successfully activated. You can now close this window." -[policies] -deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected." -deleteConfirmTitle = "Delete {{label}} policy?" - [policies.activity] -enforced = "enforced" -enforcing = "Enforcing..." -failed = "Enforcement failed" outputsUnavailable = "Policy outputs are no longer available to download." partialOutputsUnavailable = "Some policy outputs are no longer available to download." -retrying = "Busy, retrying..." runNotFound = "The enforcement run could no longer be found." -step = "step {{current}}/{{total}}" timedOut = "Enforcement timed out before the run could finish." -[policies.catalog] -compliance = "Compliance" -ingestion = "Ingestion" -retention = "Retention" -routing = "Routing" -security = "Security" - -[policies.detail] -close = "Close" -editSettings = "Edit Settings" -enforces = "Enforces" -managedByOrg = "Managed by your organization. Contact a team leader to change this policy." -noActivityDescription = "Documents will appear here once this policy runs." -noActivityTitle = "No activity yet" -onEveryUpload = "On every upload" -originalsNote = "Originals stay untouched • Enforced version saved alongside" -pause = "Pause" -recentActivity = "Recent Activity" -resume = "Resume" -retry = "Retry" -showLess = "Show less" -showMore = "Show more" -statActive = "Active" -statDataProcessed = "Data processed" -statDocsEnforced = "Docs enforced" -statusActive = "Active" -statusPaused = "Paused" - -[policies.docType] -Contracts = "Contracts" -"Financial reports" = "Financial reports" -"HR records" = "HR records" -Insurance = "Insurance" -Invoices = "Invoices" -"Legal filings" = "Legal filings" -"Medical / PHI" = "Medical / PHI" -"Tax documents" = "Tax documents" - [policies.enforcement] applying = "Applying {{names}}" applyingProgress = "Applying {{names}} ({{done}} of {{total}})" @@ -5999,80 +6009,10 @@ failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were failureTitle = "Exported without full enforcement" printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again." printPolicyAppliedTitle = "Policy applied before printing" -queued = "+{{count}} queued" successTitle = "{{names}} applied" summaryMore = "{{first}}, {{second}} and {{more}} more" summaryTwo = "{{first}} and {{second}}" -[policies.enforcement.triggerVerb] -convert = "Enforcing before convert" -default = "Enforcing" -export = "Enforcing before export" -input = "Enforcing on import" -print = "Enforcing before print" - -[policies.field] -accessLog = "Access log" -archiveAfter = "Archive after" -auditTrail = "Audit trail" -belowThreshold = "Below threshold" -destination = "Destination" -frameworks = "Frameworks" -immutableHold = "Immutable hold" -keepFor = "Keep for" -minConfidence = "Min confidence" -notify = "Notify on route" -onViolation = "When non-compliant" -webhookUrl = "Webhook URL" - -[policies.fieldOption.archiveAfter] -"1 year" = "1 year" -"30 days" = "30 days" -"90 days" = "90 days" -Never = "Never" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Flag for review" -Hold = "Hold" -"Route to bucket" = "Route to bucket" - -[policies.fieldOption.destination] -Documents = "Documents" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 year" -"3 years" = "3 years" -"30 days" = "30 days" -"7 years" = "7 years" -Indefinite = "Indefinite" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Auto-redact PHI" -"Block export" = "Block export" -"Flag for review" = "Flag for review" -"Quarantine document" = "Quarantine document" - -[policies.fields] -selectedCount = "{{count}} selected" - [policies.labels] add = "Add" addPlaceholder = "Add a label…" @@ -6111,90 +6051,6 @@ placeholder = "Select PII types" routing = "US routing numbers (ABA)" ssn = "Social Security numbers" -[policies.settings] -noneExport = "No policies currently run on export." -noneUpload = "No policies currently run on upload." -onExport = "On export" -onUpload = "On upload" -reorderHandle = "Drag to reorder" -runOrderDesc = "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder." -title = "Policy settings" - -[policies.sidebar] -activeCount = "{{count}} active" -infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps." -loading = "Loading…" -optionsAriaLabel = "Policy options" -policySettings = "Policy settings" -railAriaLabel = "{{label}} policy — {{status}}" -railSuffixActive = " (Active)" -railSuffixPaused = " (Paused)" -retryFailed = "Retry failed policies ({{count}})" -rowProgress = "{{completed}} of {{total}} files processed" -setUp = "Set up" -title = "Policies" -upgradeToEnterprise = "Upgrade to enterprise" -whatIsPolicy = "What is a policy?" - -[policies.status] -active = "Active" -paused = "Paused" -setup = "Set up" - -[policies.toolConfig] -enableAriaLabel = "Enable {{tool}}" -infoAriaLabel = "What does {{tool}} do?" - -[policies.toolConfig.info] -redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document." -sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it." -watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page." - -[policies.wizard] -allDocTypesDescription = "Enable the Classification policy to filter by document type." -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" -editTitle = "Edit {{label}} Policy" -enablePolicy = "Enable Policy" -filenameAutoNumber = "Auto-number" -filenamePositionAria = "Filename position" -filenamePrefix = "Prefix" -filenameSuffix = "Suffix" -filenameTextAria = "Filename text" -filenameTextPlaceholder = "Text to add (optional)" -lockedDescription = "Contact a team leader to change this policy." -lockedTitle = "Managed by your organization" -maxRetriesLabel = "Max retries" -noToolsError = "Add at least one configured tool to the workflow first." -outputAsLabel = "Output as" -outputFilenameSubhead = "Output filename" -outputModeAria = "Output mode" -outputNewFile = "New file" -outputNewVersion = "New version" -outputRetriesLabel = "Output & retries" -outputSubhead = "Output" -retryDelayAria = "Retry delay minutes" -retryDelayLabel = "Retry delay (min)" -runOnExport = "Export" -runOnLabel = "Run on" -runOnSubhead = "Run on" -runOnUpload = "Upload" -saveChanges = "Save Changes" -saveError = "Couldn't save the policy. Please try again." -setupClassification = "Set up Classification" -setupTitle = "Set up {{label}} Policy" -sourcesDesc = "Choose where this policy runs and which document types it applies to." -sourcesLabel = "Sources" -stepOf = "Step {{step}} of {{total}}" -toolChainDesc = "Configure the tools this policy runs on each document." -typesSelected = "{{count}} types selected" - [policy] badgeEnforcing = "{{name}} enforcing..." badgeRan = "{{name}} policy ran on this file" @@ -6203,6 +6059,45 @@ dismiss = "Dismiss overlay" enforcingTitle = "Enforcing policy..." viewAnyway = "View file (policy still enforcing)" +[policyOption] +autoRedactPhi = "Auto-redact PHI" +blockExport = "Block export" +ccpa = "CCPA" +contracts = "Contracts" +documents = "Documents" +fedramp = "FedRAMP" +financialReports = "Financial reports" +flagForReview = "Flag for review" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Hold" +hrRecords = "HR records" +indefinite = "Indefinite" +insurance = "Insurance" +invoices = "Invoices" +iso27001 = "ISO 27001" +legalFilings = "Legal filings" +medicalPhi = "Medical / PHI" +never = "Never" +ninetyDays = "90 days" +oneYear = "1 year" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Quarantine document" +routeToBucket = "Route to bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 years" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Tax documents" +thirtyDays = "30 days" +threeYears = "3 years" +webhook = "Webhook" + [portal] comingSoon = "Coming soon" @@ -6384,14 +6279,29 @@ redirectingToEditor = "Redirecting to the editor..." [portal.billing.checkout] noClientSecret = "Edge function returned no client_secret." +stepCount = "Step {{step}} of 2" subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." -title = "Turn on the Processor plan" +title = "Add a payment method" [portal.billing.checkout.activationSlow] body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly." close = "Close" title = "Almost there" +[portal.billing.checkout.cap] +back = "Back" +continue = "Continue to payment" +error = "Couldn't set your spend limit" +finePrint = "Invoices post on the 1st. Cancel anytime and revert to the Editor plan; your policies and history stay intact." +note = "Processing pauses if you reach it. You're never billed past this, and you can change it any time." +subtitle = "You're billed only for PDFs you process past your first 500 free, never for seats — so your ceiling is yours from day one." +title = "Set your spend limit" + +[portal.billing.checkout.card] +fields = "Card number · MM / YY · CVC · ZIP" +label = "Card details" +note = "Card details collected by Stripe. Stirling never stores PAN or CVC." + [portal.billing.checkout.error] title = "Couldn't start checkout" @@ -6400,13 +6310,8 @@ body = "Your payment went through. We're switching on metered processing across hint = "Please keep this window open." title = "Activating your Processor plan..." -[portal.billing.checkout.notConfigured] -bodyAfter = "in the portal env to enable in-app checkout." -bodyBefore = "Set" -title = "Stripe not configured" - [portal.billing.enterpriseUpsell] -cta = "Build your Enterprise quote" +cta = "Explore Enterprise" description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships." eyebrow = "Volume discount · 1M+ PDFs" title = "Stirling Enterprise" @@ -6471,18 +6376,21 @@ managedTitle = "Managed in Stripe" update = "Update" [portal.billing.pdfsProcessed] -emptyPeriod = "No metered processing yet this period." +emptyPeriod = "No processing yet this period." eyebrow = "PDFs processed this period" legendValue_one = "{{formatted}} PDFs" legendValue_other = "{{formatted}} PDFs" -segbarAriaLabel = "Metered PDFs split by category" +segbarAriaLabel = "PDFs split by category" segmentAgentsDesc = "AI agent actions" segmentAgentsLabel = "Agents" segmentApiDesc = "Direct API requests" segmentApiLabel = "API" segmentAutomationDesc = "Automations & pipelines" segmentAutomationLabel = "Automation" -unit = "metered PDFs" +sizeMultiplier = "{{formatted}} PDFs used a size multiplier" +summary = "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF" +summaryNoRate = "{{unique}} unique · {{units}} meter units" +unit = "PDFs" [portal.billing.spendLimit] adjustLimit = "Adjust limit" @@ -6511,6 +6419,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" +freeRemaining_one = "{{formatted}} free PDF remaining" +freeRemaining_other = "{{formatted}} free PDFs remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." @@ -6894,8 +6804,63 @@ description = "This view hit an unexpected error. Try again, or pick another sec retry = "Try again" title = "Something went wrong on this page" +[portal.home.download] +back = "Back" +body = "Download the desktop app or self-host the server." +done = "Done" +guide = "Open the {{name}} guide" +openInBrowser = "Open in browser instead" +sectionDesktop = "Desktop app" +sectionSelfHosted = "Self-hosted" +title = "Install the Stirling PDF Editor" + +[portal.home.download.docker] +detailBody = "Run the official image on your server." +note = "Opens at :8080. Default login: admin / stirling." +tagline = "Run the server in a container" +title = "Docker" +variantFat = "Fat" +variantLite = "Ultra-Lite" +variantStandard = "Standard" + +[portal.home.download.kubernetes] +detailBody = "Install the official Helm chart." +note = "Add a PVC for /configs to persist settings." +tagline = "Deploy to a cluster with Helm" +title = "Kubernetes" + +[portal.home.download.linux] +detailBody = "Native builds for your distribution." +guideBtn = "View Linux install options" +note = "AppImage, .deb and .rpm builds are available for common distros." +tagline = "Native desktop app for your distro" +title = "Linux" + +[portal.home.download.mac] +altLabel = "Or via Homebrew" +detailBody = "Run the installer, or use Homebrew." +downloadBtn = "Download for macOS" +tagline = "Native app for Apple silicon and Intel" +title = "macOS" + +[portal.home.download.manual] +detailBody = "Run the .jar on a bare metal server." +downloadBtn = "Download the .jar" +note = "Requires Java 21+. Opens at :8080; default login admin / stirling." +runLabel = "Then run it" +tagline = "Run the .jar on a bare metal server" +title = "Manual server setup" + +[portal.home.download.windows] +altLabel = "Or via winget" +detailBody = "Run the installer, or use a package manager." +downloadBtn = "Download installer" +tagline = "Native app for Microsoft Windows" +title = "Windows" + [portal.home.editor] activeUsers = "{{n}} active" +install = "Install the editor" invite = "Invite teammates" name = "Stirling PDF Editor" open = "Open in browser" @@ -6911,12 +6876,6 @@ afternoon = "Good afternoon" evening = "Good evening" morning = "Good morning" -[portal.home.onboarding] -dismiss = "Dismiss setup" -notStarted = "Not started" -progress = "{{done}} of {{total}} done" -title = "Finish setting up" - [portal.home.onboarding.enterprise] body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment." cta = "Start Trial" @@ -6925,18 +6884,16 @@ lead = "For 250+ employees." tag = "Enterprise" [portal.home.onboarding.steps.editor] -blurb = "Deploy the desktop app, free forever." -title = "Download the PDF Editor" +blurb = "Install the desktop app or self-host" +title = "Download the editor" + +[portal.home.onboarding.steps.invite] +blurb = "Bring your team into the secure workspace" +title = "Invite teammates" [portal.home.onboarding.steps.policies] blurb = "{{active}} active · {{recommended}} recommended" -chip = "{{active}} active" -title = "Turn on policies" - -[portal.home.onboarding.steps.sources] -blurb = "{{connected}} connected · every PDF governed where it lands" -chip = "{{connected}} connected" -title = "Connect your sources" +title = "Confirm your policies" [portal.home.quickActions] subtitle = "Top tasks for today" @@ -7034,6 +6991,7 @@ all = "All" auth = "Auth" config = "Config" elevation = "Elevation" +policy = "Policy" processing = "Processing" security = "Security" @@ -7043,7 +7001,7 @@ title = "You don't have access" [portal.infrastructure.audit.metrics] config = "Config" -elevation = "Elevation" +policy = "Policy" processing = "Processing" totalEvents = "Total events · 24h" @@ -7051,6 +7009,7 @@ totalEvents = "Total events · 24h" auth = "Auth" config = "Config" elevation = "Elevation" +policy = "Policy" processing = "Processing" security = "Security" @@ -7338,26 +7297,6 @@ sources = "Sources" usage = "Usage & Billing" users = "Users" -[portal.notifications] -markAllRead = "Mark all read" -title = "Notifications" -viewAll = "View all" - -[portal.notifications.ariaLabel] -none = "Notifications, no unread" -unread_one = "Notifications, {{count}} unread" -unread_other = "Notifications, {{count}} unread" - -[portal.notifications.count] -allRead = "all read" -loading = "loading" -new_one = "{{count}} new" -new_other = "{{count}} new" - -[portal.notifications.empty] -description = "No new notifications." -title = "You're all caught up" - [portal.pipelines] subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." title = "Pipelines" @@ -7402,6 +7341,11 @@ operations_one = "Operation ({{count}})" operations_other = "Operations ({{count}})" output = "Output" removeStep = "Remove operation" +s3Configure = "Configure" +s3Done = "Done" +s3ModalTitle = "Amazon S3 output" +s3NotConfigured = "Not configured" +s3PrefixHelp = "Outputs are uploaded under this key prefix." save = "Save changes" scheduleEvery = "Run every" sources = "Sources" @@ -7421,6 +7365,7 @@ confirm = "Delete" title = "Delete pipeline?" [portal.pipelines.detail] +clearHistory = "Clear history" delete = "Delete pipeline" run = "Run now" @@ -7438,12 +7383,19 @@ total = "Pipelines" [portal.pipelines.output] folder = "Write to folder" inline = "Return files" +s3 = "Write to Amazon S3" [portal.pipelines.run] +allProcessed_one = "Nothing to run: the source's {{count}} document has already been processed." +allProcessed_other = "Nothing to run: all {{count}} documents in the sources have already been processed." completed_one = "Run completed." completed_other = "All {{count}} runs completed." empty = "Nothing to run: the sources had no documents to process." failed = "Run failed: {{error}}" +historyCleared = "History cleared. The next run reprocesses everything currently in the sources." +inFlight = "Nothing new to run: documents are still being processed from an earlier run." +parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it." +parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them." running = "Run started; still in progress." timeout = "Run is taking longer than expected; it may still finish in the background." @@ -7727,7 +7679,6 @@ managePlan = "Manage plan" volumeSuffix = "PDFs processed · last 30 days" [portal.procurement] -reset = "Reset procurement (demo)" subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." title = "Procurement" @@ -7747,23 +7698,42 @@ title = "Review your enterprise agreement" [portal.procurement.builder] addons = "Add-ons" +addressLine1 = "Address line 1" +addressLine1Placeholder = "500 Howard St" +addressLine2 = "Address line 2" +addressLine2Placeholder = "Suite, floor, building" back = "Back" businessName = "Business name" businessNamePlaceholder = "Your company" +city = "City" +cityPlaceholder = "San Francisco" +contactEmail = "Contact email" +contactEmailPlaceholder = "jane@acme.com" +contactName = "Contact name" +contactNamePlaceholder = "Jane Doe" continue = "Continue" -country = "Country" -countryEuro = "Eurozone (EUR €)" -countryUK = "United Kingdom (GBP £)" -countryUS = "United States (USD $)" eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote." generate = "Generate quote" included = "Included" indemnification = "IP indemnification" indemnificationSub = "We defend qualifying IP claims, per the EULA" -offlineLicense = "Offline / air-gapped licence" -offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance" +pdfSize = "PDF size" +poNumber = "PO number" +poNumberPlaceholder = "Optional" +postalCode = "Postal code" +postalCodePlaceholder = "94105" +posture = "Governance" +posture_count = "~{{count}} policies" +posture_essentials = "Essentials" +posture_essentialsSub = "Classification + Sharing — the defaults" +posture_governed = "Governed" +posture_governedSub = "Adds Security and Routing" +posture_regulated = "Regulated" +posture_regulatedSub = "Every category — Compliance, Retention, Ingestion" qbr = "Quarterly business reviews" qbrSub = "Your SE reviews usage and roadmap each quarter" +region = "State / region" +regionPlaceholder = "California" running = "{{annual}} / yr · {{years}}-yr {{tcv}}" s1Sub = "Your team, and the PDFs you expect to run each year." # Step 1 — volume @@ -7775,13 +7745,21 @@ s3Sub = "For the quote and the agreement it generates." # Step 3 — details s3Title = "Your details" serviceLevel = "Service level" +size_compact = "Compact" +size_compactSub = "Mostly text, under 1 MB" +size_heavy = "Heavy" +size_heavySub = "Scanned or image-heavy, 5 MB+" +size_standard = "Standard" +size_standardSub = "Mixed text and images, 1 to 5 MB" slDedicated = "Dedicated" -slDedicatedSub = "4 business hours · dedicated account manager · +30%" +slDedicatedSub = "4 business hours · dedicated SE / CSM · +$30,000/yr" slPriority = "Priority" -slPrioritySub = "Same business day · named CSM · +15%" +slPrioritySub = "Same business day · named CSM · included" slStandard = "Standard" slStandardSub = "Next business day · shared CSM · included" stepOf = "Step {{n}} of {{total}}" +taxId = "VAT / Tax ID" +taxIdPlaceholder = "Optional" term = "Term" termDiscount = "{{pct}}% multi-year commitment discount applied" title = "Build your quote" @@ -7817,14 +7795,13 @@ title = "Something went wrong" [portal.procurement.hero] company = "Your enterprise deal" -ctaAgreement = "Review & sign agreement" ctaLive = "You're live" ctaPayment = "Add payment" ctaQuote = "Review your quote" ctaTrial = "Build your quote" eyebrow = "Enterprise procurement" inviteTeammates = "Invite teammates" -keyDocs = "Key documents" +licenseKey = "Licence key" nextStep = "Next step: {{action}}" notStarted = "Not started" open = "Open procurement" @@ -7872,56 +7849,6 @@ blurb = "Evaluate Stirling against your documents and workflows." gatingAction = "Build your quote" label = "Trial" -[portal.procurement.keyDocs] -oneTimeFee = " · one-time {{amount}}" -subtitle = "Everything for each stage of your rollout, in one place." -title = "Key documents" - -[portal.procurement.keyDocs.docs.baa] -name = "Business Associate Agreement" -sub = "HIPAA · available on request" - -[portal.procurement.keyDocs.docs.bankTransfer] -name = "Bank transfer instructions" -sub = "Wire details for your AP team" - -[portal.procurement.keyDocs.docs.coi] -name = "Certificate of Insurance" -sub = "Cyber + E&O · current policy" - -[portal.procurement.keyDocs.docs.formalQuote] -name = "Formal quote" -sub = "Built to your volume, term, and service level" - -[portal.procurement.keyDocs.docs.msa] -name = "Master Services Agreement" -sub = "One signature - MSA, order form, EULA, and DPA combined" - -[portal.procurement.keyDocs.docs.purchaseOrder] -name = "Purchase order" -sub = "Issuing a PO? Upload it and we invoice against it" - -[portal.procurement.keyDocs.docs.securityReview] -name = "Custom security review" -sub = "We complete your questionnaire and join your review call" - -[portal.procurement.keyDocs.docs.soc2] -name = "SOC 2 Type II report" -sub = "Audited · NDA-gated" - -[portal.procurement.keyDocs.docs.w9] -name = "IRS Form W-9" -sub = "Stirling PDF Inc." - -[portal.procurement.keyDocs.groups] -evaluation = "Supporting your evaluation" -yourDeal = "Your deal" - -[portal.procurement.keyDocs.status] -action = "Action needed" -available = "Download" -request = "Request" - [portal.procurement.license] copied = "Copied" copy = "Copy key" @@ -7929,6 +7856,9 @@ downloadError = "Could not generate the offline licence file just yet — please downloadOffline = "Download offline licence (.lic)" hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe." label = "Your licence key" +subtitle = "Activate a self-hosted instance with this key, or keep it for your records." +title = "Your licence key" +trialFileHint = "This is your trial licence file. Once your agreement is in place, come back and download it again — unlike the online licence key, the .lic file won't update on its own." [portal.procurement.link] cta = "Link account" @@ -7948,16 +7878,9 @@ talkToSales = "Talk to sales" title = "The procurement track opens with Enterprise" [portal.procurement.milestone] -accept = "Accept & continue" -description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes." download = "Download PDF" downloadError = "Could not download the quote PDF just yet — please try again in a moment." edit = "Edit quote" -eyebrow = "Quote {{number}}" -perYear = " / yr" -preparedFor = "Prepared for {{company}}" -tcv = "{{value}} total contract value" -title = "Your quote is ready" [portal.procurement.modal] cancel = "Cancel" @@ -7984,7 +7907,6 @@ uploadTitle = "Upload your purchase order" [portal.procurement.payment] description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed." downloadInvoice = "Download invoice" -simulate = "Simulate payment received (demo)" title = "Subscription created" viewInvoice = "View & pay invoice" @@ -7994,6 +7916,21 @@ fallbackLink = "Open scheduling in a new tab" subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you." title = "Schedule a call" +[portal.procurement.setup] +airgap = "Air-gapped" +airgapSub = "Fully offline, isolated network. Includes a downloadable licence file." +cloud = "Cloud" +cloudSub = "Fully managed by Stirling. Nothing for you to run." +deployment = "Where will you run Stirling?" +seats = "Team size" +seatsHint = "Roughly how many people will use it. You can refine this when you build your quote." +seatsPlaceholder = "e.g. 250" +selfhost = "Self-hosted" +selfhostSub = "Run it in your own cloud or data centre." +start = "Start trial" +subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required." +title = "Set up your trial" + [portal.procurement.status] action = "Action needed" available = "Available" @@ -8040,26 +7977,11 @@ admin = "Admin" [portal.settings.sections] account-link = "Account link" -[portal.shell.header] -accountFallback = "Account" -accountMenu = "Account menu" -darkMode = "Dark mode" -lightMode = "Light mode" -search = "Search" -searchPlaceholder = "Search…" -signOut = "Sign out" -switchToDark = "Switch to dark theme" -switchToLight = "Switch to light theme" - [portal.shell.sidebar] appEditor = "Editor" appProcessor = "Processor" brandSuffix = "Stirling Processor" -docsCount = "{{docs}} docs" linkAccount = "Link Stirling account" -planEditor = "Editor plan" -planEnterprise = "Enterprise plan" -planProcessor = "Processor plan" primaryNav = "Primary navigation" switchApp = "Switch app" @@ -8112,7 +8034,9 @@ status = "Status" usedBy = "Policies" [portal.sources.types.editor] +description = "Documents your team has processed in the editor, across policy and AI runs." label = "Editor" +noPolicies = "No policies run from the editor yet." [portal.sources.types.folder] description = "Watch a directory on the server for new documents." @@ -8145,6 +8069,42 @@ label = "Folder depth" all = "Include subfolders" top = "Top level only" +[portal.sources.types.s3] +description = "Pull documents from an Amazon S3 or S3-compatible bucket." +label = "Amazon S3" + +[portal.sources.types.s3.fields.accessKeyId] +label = "Access key ID" + +[portal.sources.types.s3.fields.bucket] +label = "Bucket" +placeholder = "my-company-inbox" + +[portal.sources.types.s3.fields.endpoint] +helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO." +label = "Custom endpoint" +placeholder = "https://s3.example.com" + +[portal.sources.types.s3.fields.mode] +helperText = "Consume removes each object from the bucket once every policy has processed it." +label = "Read mode" + +[portal.sources.types.s3.fields.mode.options] +consume = "Consume: process each object once" +snapshot = "Snapshot: re-read the bucket every run" + +[portal.sources.types.s3.fields.prefix] +helperText = "Only objects whose keys start with this prefix are processed." +label = "Key prefix" +placeholder = "incoming/" + +[portal.sources.types.s3.fields.region] +label = "Region" +placeholder = "us-east-1" + +[portal.sources.types.s3.fields.secretAccessKey] +label = "Secret access key" + [portal.sources.types.unknown] label = "Source" @@ -8183,10 +8143,6 @@ title = "Session expired" [portal.users] title = "Users" -[portal.users.roles] -subtitle = "Every role exists on every plan — what each one can do is fixed across the org." -title = "Roles" - [portal.users.roles.admin] label = "Admin (Org owner)" summary = "Full governance over the workspace, settings and members." @@ -8233,14 +8189,11 @@ unlimited = "{{used}} · Unlimited" [portal.welcome] ariaLabel = "Welcome to Stirling PDF" -badge = "Open-source" -installEditor = "Install the Editor" -inviteTeammates = "Invite teammates" +install = "Install the editor" +invite = "Invite teammates" openInBrowser = "Open in browser" -perks = "Free forever · Self-hostable" -subtitle = "The world's most secure PDF Editor is free for teams of all sizes. Includes 60+ PDF operations and SSO." -title = "Welcome to" -titleAccent = "Stirling PDF" +productName = "PDF Editor" +stats = "30M downloads · 60+ PDF operations · Free forever" [printFile] title = "Print File" @@ -9043,6 +8996,12 @@ intro = "Enable user authentication, team management, and workspace features for learnMore = "Learn more in documentation" title = "For System Administrators" +[settings.general.loginLanding] +description = "Choose which app opens when you sign in to Stirling Cloud." +editor = "Editor" +processor = "Processor" +title = "After signing in" + [settings.general.mode] fullscreen = "Fullscreen" sidebar = "Sidebar" @@ -9251,6 +9210,11 @@ security = "Security" telegram = "Telegram" title = "Security & Authentication" +[settings.signOut] +confirm = "Are you sure you want to sign out?" +submit = "Sign out" +title = "Sign out" + [settings.team] title = "Team" @@ -9867,6 +9831,9 @@ uploadButton = "Upload to Server" title = "Stirling-PDF Survey" [swagger] +clickHere = "click here" +fallback = "If it didn't open automatically," +opening = "Opening Swagger UI in a new tab..." tags = "api,documentation,swagger,endpoints,development" title = "API Documentation" @@ -10435,9 +10402,11 @@ title = "Attachments" [viewer.bookmarks] bookmarkTitle = "Bookmark title" +bookmarkTitleRequired = "Bookmark title is required" closeSidebar = "Close bookmarks sidebar" collapseAll = "Collapse all bookmarks" expandAll = "Expand all bookmarks" +searchPlaceholder = "Search bookmarks" [viewer.comments] addComment = "Add comment" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 2fbcfcc243..78d48ef9a0 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contratos" -"Financial reports" = "Informes financieros" -"HR records" = "Registros de RR. HH." -Insurance = "Seguros" -Invoices = "Facturas" -"Legal filings" = "Presentaciones legales" -"Medical / PHI" = "Médico / PHI" -"Tax documents" = "Documentos fiscales" - [policies.enforcement] applying = "Aplicando {{names}}" applyingProgress = "Aplicando {{names}} ({{done}} de {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notificar al enrutar" onViolation = "Cuando no cumple" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 año" -"30 days" = "30 días" -"90 days" = "90 días" -Never = "Nunca" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marcar para revisión" -Hold = "Retener" -"Route to bucket" = "Enrutar al bucket" - -[policies.fieldOption.destination] -Documents = "Documentos" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 año" -"3 years" = "3 años" -"30 days" = "30 días" -"7 years" = "7 años" -Indefinite = "Indefinido" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Censurar PHI automáticamente" -"Block export" = "Bloquear exportación" -"Flag for review" = "Marcar para revisión" -"Quarantine document" = "Poner documento en cuarentena" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Descartar superposición" enforcingTitle = "Aplicando política..." viewAnyway = "Ver archivo (la política aún se está aplicando)" +[policyOption] +autoRedactPhi = "Censurar PHI automáticamente" +blockExport = "Bloquear exportación" +contracts = "Contratos" +documents = "Documentos" +fedramp = "FedRAMP" +financialReports = "Informes financieros" +flagForReview = "Marcar para revisión" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Retener" +hrRecords = "Registros de RR. HH." +indefinite = "Indefinido" +insurance = "Seguros" +invoices = "Facturas" +iso27001 = "ISO 27001" +legalFilings = "Presentaciones legales" +medicalPhi = "Médico / PHI" +never = "Nunca" +ninetyDays = "90 días" +oneYear = "1 año" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Poner documento en cuarentena" +routeToBucket = "Enrutar al bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 años" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documentos fiscales" +thirtyDays = "30 días" +threeYears = "3 años" +webhook = "Webhook" + [portal] comingSoon = "Próximamente" @@ -8067,10 +8050,6 @@ title = "Sesión caducada" [portal.users] title = "Usuarios" -[portal.users.roles] -subtitle = "Cada rol existe en todos los planes — lo que cada uno puede hacer es fijo en toda la organización." -title = "Roles" - [portal.users.roles.admin] label = "Admin (propietario de la org)" summary = "Gobierno completo sobre el espacio de trabajo, los ajustes y los miembros." diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index 1742663607..666886a8c7 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Kontratuak" -"Financial reports" = "Finantza-txostenak" -"HR records" = "Giza baliabideen erregistroak" -Insurance = "Aseguruak" -Invoices = "Fakturak" -"Legal filings" = "Legezko aurkezpenak" -"Medical / PHI" = "Medikua / PHI" -"Tax documents" = "Zerga-dokumentuak" - [policies.enforcement] applying = "{{names}} aplikatzen" applyingProgress = "{{names}} aplikatzen ({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "Jakinarazi bideratzean" onViolation = "Betetzen ez denean" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 urte" -"30 days" = "30 egun" -"90 days" = "90 egun" -Never = "Inoiz ez" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Markatu berrikusteko" -Hold = "Eutsi" -"Route to bucket" = "Bideratu bucket-era" - -[policies.fieldOption.destination] -Documents = "Dokumentuak" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 urte" -"3 years" = "3 urte" -"30 days" = "30 egun" -"7 years" = "7 urte" -Indefinite = "Mugagabe" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automatikoki ezkutatu PHI" -"Block export" = "Blokeatu esportazioa" -"Flag for review" = "Markatu berrikusteko" -"Quarantine document" = "Jarri dokumentua berrogeialdian" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Baztertu gainjartzea" enforcingTitle = "Politika betearazten..." viewAnyway = "Ikusi fitxategia (politika oraindik betearazten ari da)" +[policyOption] +autoRedactPhi = "Automatikoki ezkutatu PHI" +blockExport = "Blokeatu esportazioa" +contracts = "Kontratuak" +documents = "Dokumentuak" +fedramp = "FedRAMP" +financialReports = "Finantza-txostenak" +flagForReview = "Markatu berrikusteko" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Eutsi" +hrRecords = "Giza baliabideen erregistroak" +indefinite = "Mugagabe" +insurance = "Aseguruak" +invoices = "Fakturak" +iso27001 = "ISO 27001" +legalFilings = "Legezko aurkezpenak" +medicalPhi = "Medikua / PHI" +never = "Inoiz ez" +ninetyDays = "90 egun" +oneYear = "1 urte" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Jarri dokumentua berrogeialdian" +routeToBucket = "Bideratu bucket-era" +s3Bucket = "S3 bucket" +sevenYears = "7 urte" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Zerga-dokumentuak" +thirtyDays = "30 egun" +threeYears = "3 urte" +webhook = "Webhook" + [portal] comingSoon = "Laster" @@ -8067,10 +8050,6 @@ title = "Saioa iraungi da" [portal.users] title = "Erabiltzaileak" -[portal.users.roles] -subtitle = "Rol guztiak plan guztietan daude — bakoitzak egin dezakeena finkoa da erakunde osoan." -title = "Rolak" - [portal.users.roles.admin] label = "Admin (erakundearen jabea)" summary = "Lan-eremuaren, ezarpenen eta kideen gaineko gobernantza osoa." diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index ce465bf21f..b6e4d4903d 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "قراردادها" -"Financial reports" = "گزارش‌های مالی" -"HR records" = "سوابق منابع انسانی" -Insurance = "بیمه" -Invoices = "فاکتورها" -"Legal filings" = "پرونده‌های حقوقی" -"Medical / PHI" = "پزشکی / PHI" -"Tax documents" = "اسناد مالیاتی" - [policies.enforcement] applying = "در حال اعمال {{names}}" applyingProgress = "در حال اعمال {{names}} ({{done}} از {{total}})" @@ -6025,51 +6015,6 @@ notify = "اعلان هنگام مسیریابی" onViolation = "هنگام عدم انطباق" webhookUrl = "URL Webhook" -[policies.fieldOption.archiveAfter] -"1 year" = "۱ سال" -"30 days" = "۳۰ روز" -"90 days" = "۹۰ روز" -Never = "هرگز" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "علامت‌گذاری برای بازبینی" -Hold = "نگه داشتن" -"Route to bucket" = "مسیریابی به bucket" - -[policies.fieldOption.destination] -Documents = "اسناد" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "۱ سال" -"3 years" = "۳ سال" -"30 days" = "۳۰ روز" -"7 years" = "۷ سال" -Indefinite = "نامحدود" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "حذف خودکار PHI" -"Block export" = "مسدود کردن خروجی" -"Flag for review" = "علامت‌گذاری برای بازبینی" -"Quarantine document" = "قرنطینه کردن سند" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "بستن پوشش" enforcingTitle = "در حال اعمال خط‌مشی..." viewAnyway = "مشاهده فایل (خط‌مشی همچنان در حال اعمال است)" +[policyOption] +autoRedactPhi = "حذف خودکار PHI" +blockExport = "مسدود کردن خروجی" +contracts = "قراردادها" +documents = "اسناد" +fedramp = "FedRAMP" +financialReports = "گزارش‌های مالی" +flagForReview = "علامت‌گذاری برای بازبینی" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "نگه داشتن" +hrRecords = "سوابق منابع انسانی" +indefinite = "نامحدود" +insurance = "بیمه" +invoices = "فاکتورها" +iso27001 = "ISO 27001" +legalFilings = "پرونده‌های حقوقی" +medicalPhi = "پزشکی / PHI" +never = "هرگز" +ninetyDays = "۹۰ روز" +oneYear = "۱ سال" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "قرنطینه کردن سند" +routeToBucket = "مسیریابی به bucket" +s3Bucket = "S3 bucket" +sevenYears = "۷ سال" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "اسناد مالیاتی" +thirtyDays = "۳۰ روز" +threeYears = "۳ سال" +webhook = "Webhook" + [portal] comingSoon = "به‌زودی" @@ -8067,10 +8050,6 @@ title = "نشست منقضی شد" [portal.users] title = "کاربران" -[portal.users.roles] -subtitle = "هر نقش در هر طرح وجود دارد — کاری که هر نقش می‌تواند انجام دهد در کل سازمان ثابت است." -title = "نقش‌ها" - [portal.users.roles.admin] label = "مدیر (مالک سازمان)" summary = "حاکمیت کامل بر فضای کاری، تنظیمات و اعضا." diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index dff0ff30c4..9292811eac 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contrats" -"Financial reports" = "Rapports financiers" -"HR records" = "Dossiers RH" -Insurance = "Assurance" -Invoices = "Factures" -"Legal filings" = "Dépôts juridiques" -"Medical / PHI" = "Médical / PHI" -"Tax documents" = "Documents fiscaux" - [policies.enforcement] applying = "Application de {{names}}" applyingProgress = "Application de {{names}} ({{done}} sur {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notifier lors du routage" onViolation = "En cas de non-conformité" webhookUrl = "URL du webhook" -[policies.fieldOption.archiveAfter] -"1 year" = "1 an" -"30 days" = "30 jours" -"90 days" = "90 jours" -Never = "Jamais" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marquer pour examen" -Hold = "Mettre en attente" -"Route to bucket" = "Acheminer vers le bucket" - -[policies.fieldOption.destination] -Documents = "Documents" -"S3 bucket" = "Bucket S3" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 an" -"3 years" = "3 ans" -"30 days" = "30 jours" -"7 years" = "7 ans" -Indefinite = "Indéfiniment" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Masquer automatiquement les PHI" -"Block export" = "Bloquer l’exportation" -"Flag for review" = "Marquer pour examen" -"Quarantine document" = "Mettre le document en quarantaine" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Fermer la superposition" enforcingTitle = "Application de la politique..." viewAnyway = "Voir le fichier (politique toujours en cours d’application)" +[policyOption] +autoRedactPhi = "Masquer automatiquement les PHI" +blockExport = "Bloquer l’exportation" +contracts = "Contrats" +documents = "Documents" +fedramp = "FedRAMP" +financialReports = "Rapports financiers" +flagForReview = "Marquer pour examen" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Mettre en attente" +hrRecords = "Dossiers RH" +indefinite = "Indéfiniment" +insurance = "Assurance" +invoices = "Factures" +iso27001 = "ISO 27001" +legalFilings = "Dépôts juridiques" +medicalPhi = "Médical / PHI" +never = "Jamais" +ninetyDays = "90 jours" +oneYear = "1 an" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Mettre le document en quarantaine" +routeToBucket = "Acheminer vers le bucket" +s3Bucket = "Bucket S3" +sevenYears = "7 ans" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documents fiscaux" +thirtyDays = "30 jours" +threeYears = "3 ans" +webhook = "Webhook" + [portal] comingSoon = "Bientôt disponible" @@ -8067,10 +8050,6 @@ title = "Session expirée" [portal.users] title = "Utilisateurs" -[portal.users.roles] -subtitle = "Chaque rôle existe sur chaque plan — ce que chacun peut faire est fixe dans toute l’organisation." -title = "Rôles" - [portal.users.roles.admin] label = "Admin (propriétaire de l’organisation)" summary = "Gouvernance complète de l’espace de travail, des paramètres et des membres." diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index d44eee53e3..39370baa7e 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Conarthaí" -"Financial reports" = "Tuarascálacha airgeadais" -"HR records" = "Taifid AD" -Insurance = "Árachas" -Invoices = "Sonraisc" -"Legal filings" = "Comhdúcháin dlí" -"Medical / PHI" = "Leighis / PHI" -"Tax documents" = "Doiciméid chánach" - [policies.enforcement] applying = "Á chur {{names}} i bhfeidhm" applyingProgress = "Á chur {{names}} i bhfeidhm ({{done}} as {{total}})" @@ -6025,51 +6015,6 @@ notify = "Cuir ar an eolas ar ródú" onViolation = "Nuair nach bhfuil sé comhlíontach" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 bhliain" -"30 days" = "30 lá" -"90 days" = "90 lá" -Never = "Riamh" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marcáil le hathbhreithniú" -Hold = "Coinnigh" -"Route to bucket" = "Ródaigh chuig bucket" - -[policies.fieldOption.destination] -Documents = "Doiciméid" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 bhliain" -"3 years" = "3 bliana" -"30 days" = "30 lá" -"7 years" = "7 mbliana" -Indefinite = "Gan teorainn" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Ceil PHI go huathoibríoch" -"Block export" = "Blocáil easpórtáil" -"Flag for review" = "Marcáil le hathbhreithniú" -"Quarantine document" = "Cuir doiciméad i gcoraintín" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Dún forleagan" enforcingTitle = "Polasaí á chur i bhfeidhm..." viewAnyway = "Amharc ar chomhad (polasaí fós á chur i bhfeidhm)" +[policyOption] +autoRedactPhi = "Ceil PHI go huathoibríoch" +blockExport = "Blocáil easpórtáil" +contracts = "Conarthaí" +documents = "Doiciméid" +fedramp = "FedRAMP" +financialReports = "Tuarascálacha airgeadais" +flagForReview = "Marcáil le hathbhreithniú" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Coinnigh" +hrRecords = "Taifid AD" +indefinite = "Gan teorainn" +insurance = "Árachas" +invoices = "Sonraisc" +iso27001 = "ISO 27001" +legalFilings = "Comhdúcháin dlí" +medicalPhi = "Leighis / PHI" +never = "Riamh" +ninetyDays = "90 lá" +oneYear = "1 bhliain" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Cuir doiciméad i gcoraintín" +routeToBucket = "Ródaigh chuig bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 mbliana" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Doiciméid chánach" +thirtyDays = "30 lá" +threeYears = "3 bliana" +webhook = "Webhook" + [portal] comingSoon = "Ag teacht go luath" @@ -8067,10 +8050,6 @@ title = "Seisiún imithe in éag" [portal.users] title = "Úsáideoirí" -[portal.users.roles] -subtitle = "Tá gach ról ann ar gach plean — tá an méid is féidir le gach ceann a dhéanamh socraithe ar fud na heagraíochta." -title = "Róil" - [portal.users.roles.admin] label = "Riarachán (Úinéir eagraíochta)" summary = "Rialachas iomlán ar an spás oibre, socruithe agus baill." diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index 5baffccd4a..769ceacdb4 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "अनुबंध" -"Financial reports" = "वित्तीय रिपोर्ट" -"HR records" = "HR रिकॉर्ड" -Insurance = "बीमा" -Invoices = "इनवॉइस" -"Legal filings" = "कानूनी फ़ाइलिंग" -"Medical / PHI" = "मेडिकल / PHI" -"Tax documents" = "कर दस्तावेज़" - [policies.enforcement] applying = "{{names}} लागू किए जा रहे हैं" applyingProgress = "{{names}} लागू किए जा रहे हैं ({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "रूट पर सूचित करें" onViolation = "अनुपालन न होने पर" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 वर्ष" -"30 days" = "30 दिन" -"90 days" = "90 दिन" -Never = "कभी नहीं" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "समीक्षा के लिए फ़्लैग करें" -Hold = "होल्ड करें" -"Route to bucket" = "बकेट में रूट करें" - -[policies.fieldOption.destination] -Documents = "दस्तावेज़" -"S3 bucket" = "S3 बकेट" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 वर्ष" -"3 years" = "3 वर्ष" -"30 days" = "30 दिन" -"7 years" = "7 वर्ष" -Indefinite = "अनिश्चित काल" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI को अपने-आप redact करें" -"Block export" = "export ब्लॉक करें" -"Flag for review" = "समीक्षा के लिए फ़्लैग करें" -"Quarantine document" = "दस्तावेज़ को क्वारंटीन करें" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "ओवरले हटाएँ" enforcingTitle = "policy लागू हो रही है..." viewAnyway = "फ़ाइल देखें (policy अभी भी लागू हो रही है)" +[policyOption] +autoRedactPhi = "PHI को अपने-आप redact करें" +blockExport = "export ब्लॉक करें" +contracts = "अनुबंध" +documents = "दस्तावेज़" +fedramp = "FedRAMP" +financialReports = "वित्तीय रिपोर्ट" +flagForReview = "समीक्षा के लिए फ़्लैग करें" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "होल्ड करें" +hrRecords = "HR रिकॉर्ड" +indefinite = "अनिश्चित काल" +insurance = "बीमा" +invoices = "इनवॉइस" +iso27001 = "ISO 27001" +legalFilings = "कानूनी फ़ाइलिंग" +medicalPhi = "मेडिकल / PHI" +never = "कभी नहीं" +ninetyDays = "90 दिन" +oneYear = "1 वर्ष" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "दस्तावेज़ को क्वारंटीन करें" +routeToBucket = "बकेट में रूट करें" +s3Bucket = "S3 बकेट" +sevenYears = "7 वर्ष" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "कर दस्तावेज़" +thirtyDays = "30 दिन" +threeYears = "3 वर्ष" +webhook = "Webhook" + [portal] comingSoon = "जल्द आ रहा है" @@ -8067,10 +8050,6 @@ title = "Session समाप्त" [portal.users] title = "उपयोगकर्ता" -[portal.users.roles] -subtitle = "हर role हर plan पर मौजूद है — प्रत्येक क्या कर सकता है, यह org में fixed है।" -title = "Roles" - [portal.users.roles.admin] label = "Admin (Org owner)" summary = "Workspace, settings और members पर पूर्ण governance।" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index bd1fa92372..75df8b6e28 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Ugovori" -"Financial reports" = "Financijska izvješća" -"HR records" = "HR evidencija" -Insurance = "Osiguranje" -Invoices = "Računi" -"Legal filings" = "Pravni podnesci" -"Medical / PHI" = "Medicinski / PHI" -"Tax documents" = "Porezni dokumenti" - [policies.enforcement] applying = "Primjenjuje se {{names}}" applyingProgress = "Primjenjuje se {{names}} ({{done}} od {{total}})" @@ -6025,51 +6015,6 @@ notify = "Obavijesti pri usmjeravanju" onViolation = "Kada nije usklađeno" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 godina" -"30 days" = "30 dana" -"90 days" = "90 dana" -Never = "Nikad" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Označi za pregled" -Hold = "Zadrži" -"Route to bucket" = "Usmjeri u bucket" - -[policies.fieldOption.destination] -Documents = "Dokumenti" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 godina" -"3 years" = "3 godine" -"30 days" = "30 dana" -"7 years" = "7 godina" -Indefinite = "Neograničeno" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automatski redigiraj PHI" -"Block export" = "Blokiraj izvoz" -"Flag for review" = "Označi za pregled" -"Quarantine document" = "Stavi dokument u karantenu" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Zatvori preklapanje" enforcingTitle = "Primjena pravila..." viewAnyway = "Prikaži datoteku (pravilo se još primjenjuje)" +[policyOption] +autoRedactPhi = "Automatski redigiraj PHI" +blockExport = "Blokiraj izvoz" +contracts = "Ugovori" +documents = "Dokumenti" +fedramp = "FedRAMP" +financialReports = "Financijska izvješća" +flagForReview = "Označi za pregled" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Zadrži" +hrRecords = "HR evidencija" +indefinite = "Neograničeno" +insurance = "Osiguranje" +invoices = "Računi" +iso27001 = "ISO 27001" +legalFilings = "Pravni podnesci" +medicalPhi = "Medicinski / PHI" +never = "Nikad" +ninetyDays = "90 dana" +oneYear = "1 godina" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Stavi dokument u karantenu" +routeToBucket = "Usmjeri u bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 godina" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Porezni dokumenti" +thirtyDays = "30 dana" +threeYears = "3 godine" +webhook = "Webhook" + [portal] comingSoon = "Uskoro" @@ -8067,10 +8050,6 @@ title = "Sesija je istekla" [portal.users] title = "Korisnici" -[portal.users.roles] -subtitle = "Svaka uloga postoji na svakom planu — što svaka može raditi fiksno je na razini organizacije." -title = "Uloge" - [portal.users.roles.admin] label = "Admin (vlasnik organizacije)" summary = "Potpuno upravljanje radnim prostorom, postavkama i članovima." diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 14e56d4467..de318db739 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Szerződések" -"Financial reports" = "Pénzügyi jelentések" -"HR records" = "HR nyilvántartások" -Insurance = "Biztosítás" -Invoices = "Számlák" -"Legal filings" = "Jogi beadványok" -"Medical / PHI" = "Orvosi / PHI" -"Tax documents" = "Adóügyi dokumentumok" - [policies.enforcement] applying = "A(z) {{names}} alkalmazása" applyingProgress = "A(z) {{names}} alkalmazása ({{done}} / {{total}})" @@ -6025,51 +6015,6 @@ notify = "Értesítés útvonalon" onViolation = "Ha nem megfelelő" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 év" -"30 days" = "30 nap" -"90 days" = "90 nap" -Never = "Soha" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Megjelölés felülvizsgálatra" -Hold = "Visszatartás" -"Route to bucket" = "Továbbítás bucketbe" - -[policies.fieldOption.destination] -Documents = "Dokumentumok" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 év" -"3 years" = "3 év" -"30 days" = "30 nap" -"7 years" = "7 év" -Indefinite = "Határozatlan ideig" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI automatikus kitakarása" -"Block export" = "Exportálás blokkolása" -"Flag for review" = "Megjelölés felülvizsgálatra" -"Quarantine document" = "Dokumentum karanténba helyezése" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Átfedés elvetése" enforcingTitle = "Szabályzat érvényesítése..." viewAnyway = "Fájl megtekintése (a szabályzat érvényesítése még tart)" +[policyOption] +autoRedactPhi = "PHI automatikus kitakarása" +blockExport = "Exportálás blokkolása" +contracts = "Szerződések" +documents = "Dokumentumok" +fedramp = "FedRAMP" +financialReports = "Pénzügyi jelentések" +flagForReview = "Megjelölés felülvizsgálatra" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Visszatartás" +hrRecords = "HR nyilvántartások" +indefinite = "Határozatlan ideig" +insurance = "Biztosítás" +invoices = "Számlák" +iso27001 = "ISO 27001" +legalFilings = "Jogi beadványok" +medicalPhi = "Orvosi / PHI" +never = "Soha" +ninetyDays = "90 nap" +oneYear = "1 év" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Dokumentum karanténba helyezése" +routeToBucket = "Továbbítás bucketbe" +s3Bucket = "S3 bucket" +sevenYears = "7 év" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Adóügyi dokumentumok" +thirtyDays = "30 nap" +threeYears = "3 év" +webhook = "Webhook" + [portal] comingSoon = "Hamarosan" @@ -8067,10 +8050,6 @@ title = "Munkamenet lejárt" [portal.users] title = "Felhasználók" -[portal.users.roles] -subtitle = "Minden szerepkör minden csomagban létezik — az, hogy melyik mit tehet, szervezetszerte rögzített." -title = "Szerepkörök" - [portal.users.roles.admin] label = "Admin (szervezettulajdonos)" summary = "Teljes irányítás a munkaterület, beállítások és tagok felett." diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index efcb1e5f28..d25a0eb816 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Kontrak" -"Financial reports" = "Laporan keuangan" -"HR records" = "Catatan HR" -Insurance = "Asuransi" -Invoices = "Faktur" -"Legal filings" = "Pengajuan hukum" -"Medical / PHI" = "Medis / PHI" -"Tax documents" = "Dokumen pajak" - [policies.enforcement] applying = "Menerapkan {{names}}" applyingProgress = "Menerapkan {{names}} ({{done}} dari {{total}})" @@ -6025,51 +6015,6 @@ notify = "Beri tahu saat dirutekan" onViolation = "Saat tidak patuh" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 tahun" -"30 days" = "30 hari" -"90 days" = "90 hari" -Never = "Tidak pernah" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Tandai untuk ditinjau" -Hold = "Tahan" -"Route to bucket" = "Rutekan ke bucket" - -[policies.fieldOption.destination] -Documents = "Dokumen" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 tahun" -"3 years" = "3 tahun" -"30 days" = "30 hari" -"7 years" = "7 tahun" -Indefinite = "Tanpa batas" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Redaksi otomatis PHI" -"Block export" = "Blokir ekspor" -"Flag for review" = "Tandai untuk ditinjau" -"Quarantine document" = "Karantina dokumen" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Tutup overlay" enforcingTitle = "Menerapkan kebijakan..." viewAnyway = "Lihat file (kebijakan masih diterapkan)" +[policyOption] +autoRedactPhi = "Redaksi otomatis PHI" +blockExport = "Blokir ekspor" +contracts = "Kontrak" +documents = "Dokumen" +fedramp = "FedRAMP" +financialReports = "Laporan keuangan" +flagForReview = "Tandai untuk ditinjau" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Tahan" +hrRecords = "Catatan HR" +indefinite = "Tanpa batas" +insurance = "Asuransi" +invoices = "Faktur" +iso27001 = "ISO 27001" +legalFilings = "Pengajuan hukum" +medicalPhi = "Medis / PHI" +never = "Tidak pernah" +ninetyDays = "90 hari" +oneYear = "1 tahun" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Karantina dokumen" +routeToBucket = "Rutekan ke bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 tahun" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Dokumen pajak" +thirtyDays = "30 hari" +threeYears = "3 tahun" +webhook = "Webhook" + [portal] comingSoon = "Segera hadir" @@ -8067,10 +8050,6 @@ title = "Sesi kedaluwarsa" [portal.users] title = "Pengguna" -[portal.users.roles] -subtitle = "Setiap peran ada di setiap paket — apa yang dapat dilakukan masing-masing tetap di seluruh org." -title = "Peran" - [portal.users.roles.admin] label = "Admin (Pemilik org)" summary = "Tata kelola penuh atas workspace, pengaturan, dan anggota." diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index e86508ef96..4806e5c5e2 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contratti" -"Financial reports" = "Report finanziari" -"HR records" = "Record HR" -Insurance = "Assicurazione" -Invoices = "Fatture" -"Legal filings" = "Depositi legali" -"Medical / PHI" = "Medico / PHI" -"Tax documents" = "Documenti fiscali" - [policies.enforcement] applying = "Applicazione di {{names}}" applyingProgress = "Applicazione di {{names}} ({{done}} di {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notifica su instradamento" onViolation = "Quando non conforme" webhookUrl = "URL webhook" -[policies.fieldOption.archiveAfter] -"1 year" = "1 anno" -"30 days" = "30 giorni" -"90 days" = "90 giorni" -Never = "Mai" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Segnala per revisione" -Hold = "Trattieni" -"Route to bucket" = "Instrada al bucket" - -[policies.fieldOption.destination] -Documents = "Documenti" -"S3 bucket" = "Bucket S3" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 anno" -"3 years" = "3 anni" -"30 days" = "30 giorni" -"7 years" = "7 anni" -Indefinite = "A tempo indeterminato" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Oscura automaticamente PHI" -"Block export" = "Blocca esportazione" -"Flag for review" = "Segnala per revisione" -"Quarantine document" = "Metti documento in quarantena" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Chiudi overlay" enforcingTitle = "Applicazione criterio in corso..." viewAnyway = "Visualizza file (criterio ancora in applicazione)" +[policyOption] +autoRedactPhi = "Oscura automaticamente PHI" +blockExport = "Blocca esportazione" +contracts = "Contratti" +documents = "Documenti" +fedramp = "FedRAMP" +financialReports = "Report finanziari" +flagForReview = "Segnala per revisione" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Trattieni" +hrRecords = "Record HR" +indefinite = "A tempo indeterminato" +insurance = "Assicurazione" +invoices = "Fatture" +iso27001 = "ISO 27001" +legalFilings = "Depositi legali" +medicalPhi = "Medico / PHI" +never = "Mai" +ninetyDays = "90 giorni" +oneYear = "1 anno" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Metti documento in quarantena" +routeToBucket = "Instrada al bucket" +s3Bucket = "Bucket S3" +sevenYears = "7 anni" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documenti fiscali" +thirtyDays = "30 giorni" +threeYears = "3 anni" +webhook = "Webhook" + [portal] comingSoon = "Disponibile a breve" @@ -8067,10 +8050,6 @@ title = "Sessione scaduta" [portal.users] title = "Utenti" -[portal.users.roles] -subtitle = "Ogni ruolo esiste su ogni piano — ciò che ciascuno può fare è fisso in tutta l'organizzazione." -title = "Ruoli" - [portal.users.roles.admin] label = "Admin (proprietario org)" summary = "Governance completa su workspace, impostazioni e membri." diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index 2474e53c75..843f2c055c 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "契約書" -"Financial reports" = "財務報告書" -"HR records" = "人事記録" -Insurance = "保険" -Invoices = "請求書" -"Legal filings" = "法的提出書類" -"Medical / PHI" = "医療 / PHI" -"Tax documents" = "税務書類" - [policies.enforcement] applying = "{{names}} を適用中" applyingProgress = "{{names}} を適用中({{total}} 中 {{done}})" @@ -6025,51 +6015,6 @@ notify = "ルート時に通知" onViolation = "非準拠時" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1年" -"30 days" = "30日" -"90 days" = "90日" -Never = "なし" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "レビュー用にフラグ付け" -Hold = "保留" -"Route to bucket" = "バケットへルーティング" - -[policies.fieldOption.destination] -Documents = "ドキュメント" -"S3 bucket" = "S3 バケット" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1年" -"3 years" = "3年" -"30 days" = "30日" -"7 years" = "7年" -Indefinite = "無期限" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHIを自動墨消し" -"Block export" = "エクスポートをブロック" -"Flag for review" = "レビュー用にフラグ付け" -"Quarantine document" = "ドキュメントを隔離" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "オーバーレイを閉じる" enforcingTitle = "ポリシーを適用中..." viewAnyway = "ファイルを表示(ポリシーはまだ適用中)" +[policyOption] +autoRedactPhi = "PHIを自動墨消し" +blockExport = "エクスポートをブロック" +contracts = "契約書" +documents = "ドキュメント" +fedramp = "FedRAMP" +financialReports = "財務報告書" +flagForReview = "レビュー用にフラグ付け" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "保留" +hrRecords = "人事記録" +indefinite = "無期限" +insurance = "保険" +invoices = "請求書" +iso27001 = "ISO 27001" +legalFilings = "法的提出書類" +medicalPhi = "医療 / PHI" +never = "なし" +ninetyDays = "90日" +oneYear = "1年" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "ドキュメントを隔離" +routeToBucket = "バケットへルーティング" +s3Bucket = "S3 バケット" +sevenYears = "7年" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "税務書類" +thirtyDays = "30日" +threeYears = "3年" +webhook = "Webhook" + [portal] comingSoon = "近日公開" @@ -8067,10 +8050,6 @@ title = "セッションの期限切れ" [portal.users] title = "ユーザー" -[portal.users.roles] -subtitle = "すべてのロールはすべてのプランに存在し、各ロールでできることは組織全体で固定されています。" -title = "ロール" - [portal.users.roles.admin] label = "管理者(組織オーナー)" summary = "ワークスペース、設定、メンバーを完全に管理できます。" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index ee1bdd91fa..8eeebf1f47 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "계약서" -"Financial reports" = "재무 보고서" -"HR records" = "HR 기록" -Insurance = "보험" -Invoices = "송장" -"Legal filings" = "법적 제출 서류" -"Medical / PHI" = "의료 / PHI" -"Tax documents" = "세금 문서" - [policies.enforcement] applying = "{{names}} 적용 중" applyingProgress = "{{names}} 적용 중 ({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "라우팅 시 알림" onViolation = "비준수 시" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1년" -"30 days" = "30일" -"90 days" = "90일" -Never = "안 함" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "검토 대상으로 표시" -Hold = "보류" -"Route to bucket" = "버킷으로 라우팅" - -[policies.fieldOption.destination] -Documents = "문서" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1년" -"3 years" = "3년" -"30 days" = "30일" -"7 years" = "7년" -Indefinite = "무기한" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI 자동 수정" -"Block export" = "내보내기 차단" -"Flag for review" = "검토 대상으로 표시" -"Quarantine document" = "문서 격리" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "오버레이 닫기" enforcingTitle = "정책 적용 중..." viewAnyway = "파일 보기(정책은 계속 적용 중)" +[policyOption] +autoRedactPhi = "PHI 자동 수정" +blockExport = "내보내기 차단" +contracts = "계약서" +documents = "문서" +fedramp = "FedRAMP" +financialReports = "재무 보고서" +flagForReview = "검토 대상으로 표시" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "보류" +hrRecords = "HR 기록" +indefinite = "무기한" +insurance = "보험" +invoices = "송장" +iso27001 = "ISO 27001" +legalFilings = "법적 제출 서류" +medicalPhi = "의료 / PHI" +never = "안 함" +ninetyDays = "90일" +oneYear = "1년" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "문서 격리" +routeToBucket = "버킷으로 라우팅" +s3Bucket = "S3 bucket" +sevenYears = "7년" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "세금 문서" +thirtyDays = "30일" +threeYears = "3년" +webhook = "Webhook" + [portal] comingSoon = "곧 제공 예정" @@ -8067,10 +8050,6 @@ title = "세션 만료됨" [portal.users] title = "사용자" -[portal.users.roles] -subtitle = "모든 역할은 모든 플랜에 존재하며, 각 역할이 할 수 있는 일은 조직 전체에서 고정되어 있습니다." -title = "역할" - [portal.users.roles.admin] label = "관리자(조직 소유자)" summary = "워크스페이스, 설정 및 구성원에 대한 전체 거버넌스." diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 648f8655f3..a9b9bab959 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "കരാറുകൾ" -"Financial reports" = "സാമ്പത്തിക റിപ്പോർട്ടുകൾ" -"HR records" = "HR രേഖകൾ" -Insurance = "ഇൻഷുറൻസ്" -Invoices = "ഇൻവോയിസുകൾ" -"Legal filings" = "നിയമ ഫയലിംഗുകൾ" -"Medical / PHI" = "മെഡിക്കൽ / PHI" -"Tax documents" = "നികുതി രേഖകൾ" - [policies.enforcement] applying = "പ്രാബല്യപ്പെടുത്തുന്നു {{names}}" applyingProgress = "പ്രാബല്യപ്പെടുത്തുന്നു {{names}} ({{total}} ൽ {{done}})" @@ -6025,51 +6015,6 @@ notify = "റൂട്ടിൽ അറിയിക്കുക" onViolation = "അനുസരണക്കേട് ഉണ്ടായാൽ" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 വർഷം" -"30 days" = "30 ദിവസം" -"90 days" = "90 ദിവസം" -Never = "ഒരിക്കലും ഇല്ല" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "അവലോകനത്തിനായി അടയാളപ്പെടുത്തുക" -Hold = "ഹോൾഡ് ചെയ്യുക" -"Route to bucket" = "ബക്കറ്റിലേക്ക് റൂട്ട് ചെയ്യുക" - -[policies.fieldOption.destination] -Documents = "രേഖകൾ" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 വർഷം" -"3 years" = "3 വർഷം" -"30 days" = "30 ദിവസം" -"7 years" = "7 വർഷം" -Indefinite = "അനിശ്ചിതകാലം" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI സ്വയമേവ റിഡാക്റ്റ് ചെയ്യുക" -"Block export" = "എക്സ്പോർട്ട് തടയുക" -"Flag for review" = "അവലോകനത്തിനായി അടയാളപ്പെടുത്തുക" -"Quarantine document" = "രേഖ ക്വാറന്റൈനിൽ ഇടുക" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "ഓവർലേ നിരസിക്കുക" enforcingTitle = "നയം നടപ്പിലാക്കുന്നു..." viewAnyway = "ഫയൽ കാണുക (നയം ഇപ്പോഴും നടപ്പിലാക്കുന്നു)" +[policyOption] +autoRedactPhi = "PHI സ്വയമേവ റിഡാക്റ്റ് ചെയ്യുക" +blockExport = "എക്സ്പോർട്ട് തടയുക" +contracts = "കരാറുകൾ" +documents = "രേഖകൾ" +fedramp = "FedRAMP" +financialReports = "സാമ്പത്തിക റിപ്പോർട്ടുകൾ" +flagForReview = "അവലോകനത്തിനായി അടയാളപ്പെടുത്തുക" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "ഹോൾഡ് ചെയ്യുക" +hrRecords = "HR രേഖകൾ" +indefinite = "അനിശ്ചിതകാലം" +insurance = "ഇൻഷുറൻസ്" +invoices = "ഇൻവോയിസുകൾ" +iso27001 = "ISO 27001" +legalFilings = "നിയമ ഫയലിംഗുകൾ" +medicalPhi = "മെഡിക്കൽ / PHI" +never = "ഒരിക്കലും ഇല്ല" +ninetyDays = "90 ദിവസം" +oneYear = "1 വർഷം" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "രേഖ ക്വാറന്റൈനിൽ ഇടുക" +routeToBucket = "ബക്കറ്റിലേക്ക് റൂട്ട് ചെയ്യുക" +s3Bucket = "S3 bucket" +sevenYears = "7 വർഷം" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "നികുതി രേഖകൾ" +thirtyDays = "30 ദിവസം" +threeYears = "3 വർഷം" +webhook = "Webhook" + [portal] comingSoon = "ഉടൻ വരുന്നു" @@ -8067,10 +8050,6 @@ title = "Session കാലഹരണപ്പെട്ടു" [portal.users] title = "ഉപയോക്താക്കൾ" -[portal.users.roles] -subtitle = "എല്ലാ plan-ലും ഓരോ role ഉണ്ട് — ഓരോന്നിന് ചെയ്യാനാവുന്നത് org-ലാകെ നിശ്ചിതമാണ്." -title = "Roles" - [portal.users.roles.admin] label = "Admin (Org owner)" summary = "Workspace, settings, members എന്നിവയിൽ പൂർണ്ണ governance." diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index 91a56b2594..6a364ede3f 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contracten" -"Financial reports" = "Financiële rapporten" -"HR records" = "HR-dossiers" -Insurance = "Verzekeringen" -Invoices = "Facturen" -"Legal filings" = "Juridische indieningen" -"Medical / PHI" = "Medisch / PHI" -"Tax documents" = "Belastingdocumenten" - [policies.enforcement] applying = "Bezig met toepassen van {{names}}" applyingProgress = "Bezig met toepassen van {{names}} ({{done}} van {{total}})" @@ -6025,51 +6015,6 @@ notify = "Melden bij routering" onViolation = "Bij niet-naleving" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 jaar" -"30 days" = "30 dagen" -"90 days" = "90 dagen" -Never = "Nooit" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Markeren voor beoordeling" -Hold = "Vasthouden" -"Route to bucket" = "Routeren naar bucket" - -[policies.fieldOption.destination] -Documents = "Documenten" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 jaar" -"3 years" = "3 jaar" -"30 days" = "30 dagen" -"7 years" = "7 jaar" -Indefinite = "Onbepaalde tijd" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI automatisch redigeren" -"Block export" = "Export blokkeren" -"Flag for review" = "Markeren voor beoordeling" -"Quarantine document" = "Document in quarantaine plaatsen" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Overlay sluiten" enforcingTitle = "Beleid afdwingen..." viewAnyway = "Bestand bekijken (beleid wordt nog afgedwongen)" +[policyOption] +autoRedactPhi = "PHI automatisch redigeren" +blockExport = "Export blokkeren" +contracts = "Contracten" +documents = "Documenten" +fedramp = "FedRAMP" +financialReports = "Financiële rapporten" +flagForReview = "Markeren voor beoordeling" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Vasthouden" +hrRecords = "HR-dossiers" +indefinite = "Onbepaalde tijd" +insurance = "Verzekeringen" +invoices = "Facturen" +iso27001 = "ISO 27001" +legalFilings = "Juridische indieningen" +medicalPhi = "Medisch / PHI" +never = "Nooit" +ninetyDays = "90 dagen" +oneYear = "1 jaar" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Document in quarantaine plaatsen" +routeToBucket = "Routeren naar bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 jaar" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Belastingdocumenten" +thirtyDays = "30 dagen" +threeYears = "3 jaar" +webhook = "Webhook" + [portal] comingSoon = "Binnenkort beschikbaar" @@ -8067,10 +8050,6 @@ title = "Sessie verlopen" [portal.users] title = "Gebruikers" -[portal.users.roles] -subtitle = "Elke rol bestaat op elk abonnement — wat elke rol kan doen is vastgelegd voor de hele organisatie." -title = "Rollen" - [portal.users.roles.admin] label = "Admin (organisatie-eigenaar)" summary = "Volledig beheer over de workspace, instellingen en leden." diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index e67cad4214..1fd68531a2 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Kontrakter" -"Financial reports" = "Finansrapporter" -"HR records" = "HR-poster" -Insurance = "Forsikring" -Invoices = "Fakturaer" -"Legal filings" = "Juridiske innsendelser" -"Medical / PHI" = "Medisinsk / PHI" -"Tax documents" = "Skattedokumenter" - [policies.enforcement] applying = "Tar i bruk {{names}}" applyingProgress = "Tar i bruk {{names}} ({{done}} av {{total}})" @@ -6025,51 +6015,6 @@ notify = "Varsle ved ruting" onViolation = "Ved manglende samsvar" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 år" -"30 days" = "30 dager" -"90 days" = "90 dager" -Never = "Aldri" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Merk for gjennomgang" -Hold = "Hold tilbake" -"Route to bucket" = "Rut til bucket" - -[policies.fieldOption.destination] -Documents = "Dokumenter" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 år" -"3 years" = "3 år" -"30 days" = "30 dager" -"7 years" = "7 år" -Indefinite = "På ubestemt tid" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Sladd PHI automatisk" -"Block export" = "Blokker eksport" -"Flag for review" = "Merk for gjennomgang" -"Quarantine document" = "Sett dokument i karantene" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Avvis overlegg" enforcingTitle = "Håndhever policy..." viewAnyway = "Vis fil (policy håndheves fortsatt)" +[policyOption] +autoRedactPhi = "Sladd PHI automatisk" +blockExport = "Blokker eksport" +contracts = "Kontrakter" +documents = "Dokumenter" +fedramp = "FedRAMP" +financialReports = "Finansrapporter" +flagForReview = "Merk for gjennomgang" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Hold tilbake" +hrRecords = "HR-poster" +indefinite = "På ubestemt tid" +insurance = "Forsikring" +invoices = "Fakturaer" +iso27001 = "ISO 27001" +legalFilings = "Juridiske innsendelser" +medicalPhi = "Medisinsk / PHI" +never = "Aldri" +ninetyDays = "90 dager" +oneYear = "1 år" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Sett dokument i karantene" +routeToBucket = "Rut til bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 år" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Skattedokumenter" +thirtyDays = "30 dager" +threeYears = "3 år" +webhook = "Webhook" + [portal] comingSoon = "Kommer snart" @@ -8067,10 +8050,6 @@ title = "Økten er utløpt" [portal.users] title = "Brukere" -[portal.users.roles] -subtitle = "Alle roller finnes på alle abonnementer — hva hver enkelt kan gjøre, er fast på tvers av organisasjonen." -title = "Roller" - [portal.users.roles.admin] label = "Administrator (org-eier)" summary = "Full styring over arbeidsområdet, innstillinger og medlemmer." diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index fc4f6119f7..2309dd6966 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Umowy" -"Financial reports" = "Raporty finansowe" -"HR records" = "Dokumentacja HR" -Insurance = "Ubezpieczenia" -Invoices = "Faktury" -"Legal filings" = "Dokumenty prawne" -"Medical / PHI" = "Medyczne / PHI" -"Tax documents" = "Dokumenty podatkowe" - [policies.enforcement] applying = "Stosowanie {{names}}" applyingProgress = "Stosowanie {{names}} ({{done}} z {{total}})" @@ -6025,51 +6015,6 @@ notify = "Powiadom przy kierowaniu" onViolation = "Gdy niezgodne" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 rok" -"30 days" = "30 dni" -"90 days" = "90 dni" -Never = "Nigdy" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Oznacz do przeglądu" -Hold = "Wstrzymaj" -"Route to bucket" = "Skieruj do bucket" - -[policies.fieldOption.destination] -Documents = "Dokumenty" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 rok" -"3 years" = "3 lata" -"30 days" = "30 dni" -"7 years" = "7 lat" -Indefinite = "Bezterminowo" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automatycznie zredaguj PHI" -"Block export" = "Zablokuj eksport" -"Flag for review" = "Oznacz do przeglądu" -"Quarantine document" = "Poddaj dokument kwarantannie" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Zamknij nakładkę" enforcingTitle = "Wymuszanie zasady..." viewAnyway = "Wyświetl plik (zasada nadal jest wymuszana)" +[policyOption] +autoRedactPhi = "Automatycznie zredaguj PHI" +blockExport = "Zablokuj eksport" +contracts = "Umowy" +documents = "Dokumenty" +fedramp = "FedRAMP" +financialReports = "Raporty finansowe" +flagForReview = "Oznacz do przeglądu" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Wstrzymaj" +hrRecords = "Dokumentacja HR" +indefinite = "Bezterminowo" +insurance = "Ubezpieczenia" +invoices = "Faktury" +iso27001 = "ISO 27001" +legalFilings = "Dokumenty prawne" +medicalPhi = "Medyczne / PHI" +never = "Nigdy" +ninetyDays = "90 dni" +oneYear = "1 rok" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Poddaj dokument kwarantannie" +routeToBucket = "Skieruj do bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 lat" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Dokumenty podatkowe" +thirtyDays = "30 dni" +threeYears = "3 lata" +webhook = "Webhook" + [portal] comingSoon = "Wkrótce" @@ -8067,10 +8050,6 @@ title = "Sesja wygasła" [portal.users] title = "Użytkownicy" -[portal.users.roles] -subtitle = "Każda rola istnieje w każdym planie — to, co każda z nich może robić, jest stałe w całej organizacji." -title = "Role" - [portal.users.roles.admin] label = "Admin (właściciel organizacji)" summary = "Pełne zarządzanie workspace, ustawieniami i członkami." diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index 4e4f20cd9f..df95886da7 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contratos" -"Financial reports" = "Relatórios financeiros" -"HR records" = "Registros de RH" -Insurance = "Seguros" -Invoices = "Faturas" -"Legal filings" = "Registros legais" -"Medical / PHI" = "Médico / PHI" -"Tax documents" = "Documentos fiscais" - [policies.enforcement] applying = "Aplicando {{names}}" applyingProgress = "Aplicando {{names}} ({{done}} de {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notificar no roteamento" onViolation = "Quando não conforme" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 ano" -"30 days" = "30 dias" -"90 days" = "90 dias" -Never = "Nunca" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marcar para revisão" -Hold = "Reter" -"Route to bucket" = "Rotear para bucket" - -[policies.fieldOption.destination] -Documents = "Documentos" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 ano" -"3 years" = "3 anos" -"30 days" = "30 dias" -"7 years" = "7 anos" -Indefinite = "Indefinidamente" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Redigir PHI automaticamente" -"Block export" = "Bloquear exportação" -"Flag for review" = "Marcar para revisão" -"Quarantine document" = "Colocar documento em quarentena" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Dispensar sobreposição" enforcingTitle = "Aplicando política..." viewAnyway = "Ver arquivo (política ainda sendo aplicada)" +[policyOption] +autoRedactPhi = "Redigir PHI automaticamente" +blockExport = "Bloquear exportação" +contracts = "Contratos" +documents = "Documentos" +fedramp = "FedRAMP" +financialReports = "Relatórios financeiros" +flagForReview = "Marcar para revisão" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Reter" +hrRecords = "Registros de RH" +indefinite = "Indefinidamente" +insurance = "Seguros" +invoices = "Faturas" +iso27001 = "ISO 27001" +legalFilings = "Registros legais" +medicalPhi = "Médico / PHI" +never = "Nunca" +ninetyDays = "90 dias" +oneYear = "1 ano" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Colocar documento em quarentena" +routeToBucket = "Rotear para bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 anos" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documentos fiscais" +thirtyDays = "30 dias" +threeYears = "3 anos" +webhook = "Webhook" + [portal] comingSoon = "Em breve" @@ -8067,10 +8050,6 @@ title = "Sessão expirada" [portal.users] title = "Usuários" -[portal.users.roles] -subtitle = "Toda função existe em todo plano — o que cada uma pode fazer é fixo em toda a organização." -title = "Funções" - [portal.users.roles.admin] label = "Admin (proprietário da organização)" summary = "Governança total sobre o workspace, configurações e membros." diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 288e2d6e5f..c7b1a45ee9 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contratos" -"Financial reports" = "Relatórios financeiros" -"HR records" = "Registos de RH" -Insurance = "Seguros" -Invoices = "Faturas" -"Legal filings" = "Documentação legal" -"Medical / PHI" = "Médico / PHI" -"Tax documents" = "Documentos fiscais" - [policies.enforcement] applying = "A aplicar {{names}}" applyingProgress = "A aplicar {{names}} ({{done}} de {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notificar no encaminhamento" onViolation = "Quando não conforme" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 ano" -"30 days" = "30 dias" -"90 days" = "90 dias" -Never = "Nunca" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Assinalar para revisão" -Hold = "Reter" -"Route to bucket" = "Encaminhar para bucket" - -[policies.fieldOption.destination] -Documents = "Documentos" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 ano" -"3 years" = "3 anos" -"30 days" = "30 dias" -"7 years" = "7 anos" -Indefinite = "Indefinidamente" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Ocultar automaticamente PHI" -"Block export" = "Bloquear exportação" -"Flag for review" = "Assinalar para revisão" -"Quarantine document" = "Colocar documento em quarentena" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Dispensar sobreposição" enforcingTitle = "A aplicar política..." viewAnyway = "Ver ficheiro (política ainda em aplicação)" +[policyOption] +autoRedactPhi = "Ocultar automaticamente PHI" +blockExport = "Bloquear exportação" +contracts = "Contratos" +documents = "Documentos" +fedramp = "FedRAMP" +financialReports = "Relatórios financeiros" +flagForReview = "Assinalar para revisão" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Reter" +hrRecords = "Registos de RH" +indefinite = "Indefinidamente" +insurance = "Seguros" +invoices = "Faturas" +iso27001 = "ISO 27001" +legalFilings = "Documentação legal" +medicalPhi = "Médico / PHI" +never = "Nunca" +ninetyDays = "90 dias" +oneYear = "1 ano" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Colocar documento em quarentena" +routeToBucket = "Encaminhar para bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 anos" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documentos fiscais" +thirtyDays = "30 dias" +threeYears = "3 anos" +webhook = "Webhook" + [portal] comingSoon = "Em breve" @@ -8067,10 +8050,6 @@ title = "Sessão expirada" [portal.users] title = "Utilizadores" -[portal.users.roles] -subtitle = "Todas as funções existem em todos os planos — o que cada uma pode fazer é fixo em toda a organização." -title = "Funções" - [portal.users.roles.admin] label = "Admin (Proprietário da organização)" summary = "Governação total sobre o workspace, definições e membros." diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index e99f79b6cd..00c9a155ae 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Contracte" -"Financial reports" = "Rapoarte financiare" -"HR records" = "Înregistrări HR" -Insurance = "Asigurări" -Invoices = "Facturi" -"Legal filings" = "Depuneri juridice" -"Medical / PHI" = "Medical / PHI" -"Tax documents" = "Documente fiscale" - [policies.enforcement] applying = "Se aplică {{names}}" applyingProgress = "Se aplică {{names}} ({{done}} din {{total}})" @@ -6025,51 +6015,6 @@ notify = "Notifică la rutare" onViolation = "Când nu este conform" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 an" -"30 days" = "30 de zile" -"90 days" = "90 de zile" -Never = "Niciodată" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Marchează pentru revizuire" -Hold = "Reține" -"Route to bucket" = "Rutează către bucket" - -[policies.fieldOption.destination] -Documents = "Documente" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 an" -"3 years" = "3 ani" -"30 days" = "30 de zile" -"7 years" = "7 ani" -Indefinite = "Pe termen nelimitat" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Anonimizează automat PHI" -"Block export" = "Blochează exportul" -"Flag for review" = "Marchează pentru revizuire" -"Quarantine document" = "Pune documentul în carantină" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Închide suprapunerea" enforcingTitle = "Se aplică politica..." viewAnyway = "Vezi fișierul (politica încă se aplică)" +[policyOption] +autoRedactPhi = "Anonimizează automat PHI" +blockExport = "Blochează exportul" +contracts = "Contracte" +documents = "Documente" +fedramp = "FedRAMP" +financialReports = "Rapoarte financiare" +flagForReview = "Marchează pentru revizuire" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Reține" +hrRecords = "Înregistrări HR" +indefinite = "Pe termen nelimitat" +insurance = "Asigurări" +invoices = "Facturi" +iso27001 = "ISO 27001" +legalFilings = "Depuneri juridice" +medicalPhi = "Medical / PHI" +never = "Niciodată" +ninetyDays = "90 de zile" +oneYear = "1 an" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Pune documentul în carantină" +routeToBucket = "Rutează către bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 ani" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Documente fiscale" +thirtyDays = "30 de zile" +threeYears = "3 ani" +webhook = "Webhook" + [portal] comingSoon = "În curând" @@ -8067,10 +8050,6 @@ title = "Sesiune expirată" [portal.users] title = "Utilizatori" -[portal.users.roles] -subtitle = "Fiecare rol există în fiecare plan — ce poate face fiecare este fix în întreaga org." -title = "Roluri" - [portal.users.roles.admin] label = "Admin (proprietar org)" summary = "Guvernanță completă asupra spațiului de lucru, setărilor și membrilor." diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index 0c73250687..c4ceb3281e 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Контракты" -"Financial reports" = "Финансовые отчёты" -"HR records" = "Кадровые записи" -Insurance = "Страхование" -Invoices = "Счета" -"Legal filings" = "Юридические документы" -"Medical / PHI" = "Медицинские / PHI" -"Tax documents" = "Налоговые документы" - [policies.enforcement] applying = "Применение {{names}}" applyingProgress = "Применение {{names}} ({{done}} из {{total}})" @@ -6025,51 +6015,6 @@ notify = "Уведомлять при маршрутизации" onViolation = "При несоответствии" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 год" -"30 days" = "30 дней" -"90 days" = "90 дней" -Never = "Никогда" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Пометить для проверки" -Hold = "Удержать" -"Route to bucket" = "Маршрутизировать в bucket" - -[policies.fieldOption.destination] -Documents = "Документы" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 год" -"3 years" = "3 года" -"30 days" = "30 дней" -"7 years" = "7 лет" -Indefinite = "Бессрочно" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Автоматически редактировать PHI" -"Block export" = "Заблокировать экспорт" -"Flag for review" = "Пометить для проверки" -"Quarantine document" = "Поместить документ в карантин" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Закрыть оверлей" enforcingTitle = "Применение политики..." viewAnyway = "Показать файл (политика всё ещё применяется)" +[policyOption] +autoRedactPhi = "Автоматически редактировать PHI" +blockExport = "Заблокировать экспорт" +contracts = "Контракты" +documents = "Документы" +fedramp = "FedRAMP" +financialReports = "Финансовые отчёты" +flagForReview = "Пометить для проверки" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Удержать" +hrRecords = "Кадровые записи" +indefinite = "Бессрочно" +insurance = "Страхование" +invoices = "Счета" +iso27001 = "ISO 27001" +legalFilings = "Юридические документы" +medicalPhi = "Медицинские / PHI" +never = "Никогда" +ninetyDays = "90 дней" +oneYear = "1 год" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Поместить документ в карантин" +routeToBucket = "Маршрутизировать в bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 лет" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Налоговые документы" +thirtyDays = "30 дней" +threeYears = "3 года" +webhook = "Webhook" + [portal] comingSoon = "Скоро" @@ -8067,10 +8050,6 @@ title = "Сессия истекла" [portal.users] title = "Пользователи" -[portal.users.roles] -subtitle = "Каждая роль есть в каждом плане — набор действий для каждой роли фиксирован в рамках организации." -title = "Роли" - [portal.users.roles.admin] label = "Администратор (владелец организации)" summary = "Полное управление рабочим пространством, настройками и участниками." diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 96349ceb1a..1ab3fe48ee 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Zmluvy" -"Financial reports" = "Finančné správy" -"HR records" = "HR záznamy" -Insurance = "Poistenie" -Invoices = "Faktúry" -"Legal filings" = "Právne podania" -"Medical / PHI" = "Zdravotné / PHI" -"Tax documents" = "Daňové dokumenty" - [policies.enforcement] applying = "Uplatňuje sa {{names}}" applyingProgress = "Uplatňuje sa {{names}} ({{done}} z {{total}})" @@ -6025,51 +6015,6 @@ notify = "Upozorniť pri smerovaní" onViolation = "Pri nesúlade" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 rok" -"30 days" = "30 dní" -"90 days" = "90 dní" -Never = "Nikdy" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Označiť na kontrolu" -Hold = "Pozdržať" -"Route to bucket" = "Smerovať do bucketu" - -[policies.fieldOption.destination] -Documents = "Dokumenty" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 rok" -"3 years" = "3 roky" -"30 days" = "30 dní" -"7 years" = "7 rokov" -Indefinite = "Na neurčito" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automaticky začierniť PHI" -"Block export" = "Blokovať export" -"Flag for review" = "Označiť na kontrolu" -"Quarantine document" = "Umiestniť dokument do karantény" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Zavrieť prekrytie" enforcingTitle = "Vynucuje sa zásada..." viewAnyway = "Zobraziť súbor (zásada sa stále vynucuje)" +[policyOption] +autoRedactPhi = "Automaticky začierniť PHI" +blockExport = "Blokovať export" +contracts = "Zmluvy" +documents = "Dokumenty" +fedramp = "FedRAMP" +financialReports = "Finančné správy" +flagForReview = "Označiť na kontrolu" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Pozdržať" +hrRecords = "HR záznamy" +indefinite = "Na neurčito" +insurance = "Poistenie" +invoices = "Faktúry" +iso27001 = "ISO 27001" +legalFilings = "Právne podania" +medicalPhi = "Zdravotné / PHI" +never = "Nikdy" +ninetyDays = "90 dní" +oneYear = "1 rok" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Umiestniť dokument do karantény" +routeToBucket = "Smerovať do bucketu" +s3Bucket = "S3 bucket" +sevenYears = "7 rokov" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Daňové dokumenty" +thirtyDays = "30 dní" +threeYears = "3 roky" +webhook = "Webhook" + [portal] comingSoon = "Už čoskoro" @@ -8067,10 +8050,6 @@ title = "Relácia vypršala" [portal.users] title = "Používatelia" -[portal.users.roles] -subtitle = "Každá rola existuje v každom pláne — to, čo môže každá robiť, je pevne dané naprieč organizáciou." -title = "Roly" - [portal.users.roles.admin] label = "Admin (vlastník organizácie)" summary = "Úplná správa workspace, nastavení a členov." diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 1d782b38a5..dd7bfc7292 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Pogodbe" -"Financial reports" = "Finančna poročila" -"HR records" = "Kadrovske evidence" -Insurance = "Zavarovanje" -Invoices = "Računi" -"Legal filings" = "Pravne vloge" -"Medical / PHI" = "Medicinsko / PHI" -"Tax documents" = "Davčni dokumenti" - [policies.enforcement] applying = "Uveljavljanje {{names}}" applyingProgress = "Uveljavljanje {{names}} ({{done}} od {{total}})" @@ -6025,51 +6015,6 @@ notify = "Obvesti ob usmerjanju" onViolation = "Ob neskladnosti" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 leto" -"30 days" = "30 dni" -"90 days" = "90 dni" -Never = "Nikoli" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Označi za pregled" -Hold = "Zadrži" -"Route to bucket" = "Usmeri v vedro" - -[policies.fieldOption.destination] -Documents = "Dokumenti" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 leto" -"3 years" = "3 leta" -"30 days" = "30 dni" -"7 years" = "7 let" -Indefinite = "Nedoločeno" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Samodejno prekrij PHI" -"Block export" = "Blokiraj izvoz" -"Flag for review" = "Označi za pregled" -"Quarantine document" = "Daj dokument v karanteno" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Opusti prekrivanje" enforcingTitle = "Uveljavljanje pravilnika..." viewAnyway = "Prikaži datoteko (pravilnik se še uveljavlja)" +[policyOption] +autoRedactPhi = "Samodejno prekrij PHI" +blockExport = "Blokiraj izvoz" +contracts = "Pogodbe" +documents = "Dokumenti" +fedramp = "FedRAMP" +financialReports = "Finančna poročila" +flagForReview = "Označi za pregled" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Zadrži" +hrRecords = "Kadrovske evidence" +indefinite = "Nedoločeno" +insurance = "Zavarovanje" +invoices = "Računi" +iso27001 = "ISO 27001" +legalFilings = "Pravne vloge" +medicalPhi = "Medicinsko / PHI" +never = "Nikoli" +ninetyDays = "90 dni" +oneYear = "1 leto" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Daj dokument v karanteno" +routeToBucket = "Usmeri v vedro" +s3Bucket = "S3 bucket" +sevenYears = "7 let" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Davčni dokumenti" +thirtyDays = "30 dni" +threeYears = "3 leta" +webhook = "Webhook" + [portal] comingSoon = "Kmalu na voljo" @@ -8067,10 +8050,6 @@ title = "Seja je potekla" [portal.users] title = "Uporabniki" -[portal.users.roles] -subtitle = "Vsaka vloga obstaja v vsakem paketu — kaj lahko vsaka naredi, je fiksno v celotni organizaciji." -title = "Vloge" - [portal.users.roles.admin] label = "Admin (lastnik organizacije)" summary = "Popolno upravljanje delovnega prostora, nastavitev in članov." diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index fbd8cb5b03..2ddd6060b0 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Ugovori" -"Financial reports" = "Finansijski izveštaji" -"HR records" = "HR evidencija" -Insurance = "Osiguranje" -Invoices = "Fakture" -"Legal filings" = "Pravne prijave" -"Medical / PHI" = "Medicinsko / PHI" -"Tax documents" = "Poreski dokumenti" - [policies.enforcement] applying = "Primena {{names}}" applyingProgress = "Primena {{names}} ({{done}} od {{total}})" @@ -6025,51 +6015,6 @@ notify = "Obavesti pri usmeravanju" onViolation = "Kada nije usklađeno" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 godina" -"30 days" = "30 dana" -"90 days" = "90 dana" -Never = "Nikad" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Označi za pregled" -Hold = "Zadrži" -"Route to bucket" = "Usmeri u bucket" - -[policies.fieldOption.destination] -Documents = "Dokumenti" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 godinu" -"3 years" = "3 godine" -"30 days" = "30 dana" -"7 years" = "7 godina" -Indefinite = "Neograničeno" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automatski rediguj PHI" -"Block export" = "Blokiraj izvoz" -"Flag for review" = "Označi za pregled" -"Quarantine document" = "Stavi dokument u karantin" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Zatvori preklop" enforcingTitle = "Primena politike..." viewAnyway = "Prikaži fajl (politika se i dalje primenjuje)" +[policyOption] +autoRedactPhi = "Automatski rediguj PHI" +blockExport = "Blokiraj izvoz" +contracts = "Ugovori" +documents = "Dokumenti" +fedramp = "FedRAMP" +financialReports = "Finansijski izveštaji" +flagForReview = "Označi za pregled" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Zadrži" +hrRecords = "HR evidencija" +indefinite = "Neograničeno" +insurance = "Osiguranje" +invoices = "Fakture" +iso27001 = "ISO 27001" +legalFilings = "Pravne prijave" +medicalPhi = "Medicinsko / PHI" +never = "Nikad" +ninetyDays = "90 dana" +oneYear = "1 godina" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Stavi dokument u karantin" +routeToBucket = "Usmeri u bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 godina" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Poreski dokumenti" +thirtyDays = "30 dana" +threeYears = "3 godine" +webhook = "Webhook" + [portal] comingSoon = "Uskoro" @@ -8067,10 +8050,6 @@ title = "Sesija je istekla" [portal.users] title = "Korisnici" -[portal.users.roles] -subtitle = "Svaka uloga postoji na svakom planu — ono što svaka može da radi je fiksno na nivou organizacije." -title = "Uloge" - [portal.users.roles.admin] label = "Admin (vlasnik organizacije)" summary = "Puno upravljanje radnim prostorom, podešavanjima i članovima." diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index a7ddadbe3b..841cce5277 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Avtal" -"Financial reports" = "Finansiella rapporter" -"HR records" = "HR-poster" -Insurance = "Försäkring" -Invoices = "Fakturor" -"Legal filings" = "Juridiska inlagor" -"Medical / PHI" = "Medicinskt / PHI" -"Tax documents" = "Skattedokument" - [policies.enforcement] applying = "Tillämpning av {{names}}" applyingProgress = "Tillämpning av {{names}} ({{done}} av {{total}})" @@ -6025,51 +6015,6 @@ notify = "Avisera vid dirigering" onViolation = "Vid bristande efterlevnad" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 år" -"30 days" = "30 dagar" -"90 days" = "90 dagar" -Never = "Aldrig" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Flagga för granskning" -Hold = "Spärra" -"Route to bucket" = "Dirigera till bucket" - -[policies.fieldOption.destination] -Documents = "Dokument" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 år" -"3 years" = "3 år" -"30 days" = "30 dagar" -"7 years" = "7 år" -Indefinite = "Tills vidare" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Automatisk maskering av PHI" -"Block export" = "Blockera export" -"Flag for review" = "Flagga för granskning" -"Quarantine document" = "Sätt dokument i karantän" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Stäng överlägg" enforcingTitle = "Tillämpar policy..." viewAnyway = "Visa fil (policy tillämpas fortfarande)" +[policyOption] +autoRedactPhi = "Automatisk maskering av PHI" +blockExport = "Blockera export" +contracts = "Avtal" +documents = "Dokument" +fedramp = "FedRAMP" +financialReports = "Finansiella rapporter" +flagForReview = "Flagga för granskning" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Spärra" +hrRecords = "HR-poster" +indefinite = "Tills vidare" +insurance = "Försäkring" +invoices = "Fakturor" +iso27001 = "ISO 27001" +legalFilings = "Juridiska inlagor" +medicalPhi = "Medicinskt / PHI" +never = "Aldrig" +ninetyDays = "90 dagar" +oneYear = "1 år" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Sätt dokument i karantän" +routeToBucket = "Dirigera till bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 år" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Skattedokument" +thirtyDays = "30 dagar" +threeYears = "3 år" +webhook = "Webhook" + [portal] comingSoon = "Kommer snart" @@ -8067,10 +8050,6 @@ title = "Sessionen har löpt ut" [portal.users] title = "Användare" -[portal.users.roles] -subtitle = "Varje roll finns på varje plan — vad var och en kan göra är fast i hela organisationen." -title = "Roller" - [portal.users.roles.admin] label = "Admin (organisationsägare)" summary = "Full styrning över arbetsytan, inställningar och medlemmar." diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index ee7f5f8b6c..55f27a8467 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "สัญญา" -"Financial reports" = "รายงานทางการเงิน" -"HR records" = "ระเบียน HR" -Insurance = "ประกันภัย" -Invoices = "ใบแจ้งหนี้" -"Legal filings" = "เอกสารยื่นทางกฎหมาย" -"Medical / PHI" = "การแพทย์ / PHI" -"Tax documents" = "เอกสารภาษี" - [policies.enforcement] applying = "กำลังนำ {{names}} ไปใช้" applyingProgress = "กำลังนำ {{names}} ไปใช้ ({{done}} จาก {{total}})" @@ -6025,51 +6015,6 @@ notify = "แจ้งเตือนเมื่อกำหนดเส้น onViolation = "เมื่อไม่เป็นไปตามข้อกำหนด" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 ปี" -"30 days" = "30 วัน" -"90 days" = "90 วัน" -Never = "ไม่เลย" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "ทำเครื่องหมายเพื่อตรวจสอบ" -Hold = "ระงับ" -"Route to bucket" = "กำหนดเส้นทางไปยัง bucket" - -[policies.fieldOption.destination] -Documents = "เอกสาร" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 ปี" -"3 years" = "3 ปี" -"30 days" = "30 วัน" -"7 years" = "7 ปี" -Indefinite = "ไม่มีกำหนด" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "ปกปิด PHI อัตโนมัติ" -"Block export" = "บล็อกการส่งออก" -"Flag for review" = "ทำเครื่องหมายเพื่อตรวจสอบ" -"Quarantine document" = "กักกันเอกสาร" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "ปิดโอเวอร์เลย์" enforcingTitle = "กำลังบังคับใช้นโยบาย..." viewAnyway = "ดูไฟล์ (นโยบายยังคงบังคับใช้อยู่)" +[policyOption] +autoRedactPhi = "ปกปิด PHI อัตโนมัติ" +blockExport = "บล็อกการส่งออก" +contracts = "สัญญา" +documents = "เอกสาร" +fedramp = "FedRAMP" +financialReports = "รายงานทางการเงิน" +flagForReview = "ทำเครื่องหมายเพื่อตรวจสอบ" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "ระงับ" +hrRecords = "ระเบียน HR" +indefinite = "ไม่มีกำหนด" +insurance = "ประกันภัย" +invoices = "ใบแจ้งหนี้" +iso27001 = "ISO 27001" +legalFilings = "เอกสารยื่นทางกฎหมาย" +medicalPhi = "การแพทย์ / PHI" +never = "ไม่เลย" +ninetyDays = "90 วัน" +oneYear = "1 ปี" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "กักกันเอกสาร" +routeToBucket = "กำหนดเส้นทางไปยัง bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 ปี" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "เอกสารภาษี" +thirtyDays = "30 วัน" +threeYears = "3 ปี" +webhook = "Webhook" + [portal] comingSoon = "เร็วๆ นี้" @@ -8067,10 +8050,6 @@ title = "เซสชันหมดอายุ" [portal.users] title = "ผู้ใช้" -[portal.users.roles] -subtitle = "ทุกบทบาทมีอยู่ในทุกแผน — สิ่งที่แต่ละบทบาททำได้ถูกกำหนดตายตัวทั่วทั้งองค์กร" -title = "บทบาท" - [portal.users.roles.admin] label = "Admin (เจ้าขององค์กร)" summary = "การกำกับดูแลเต็มรูปแบบเหนือ workspace, การตั้งค่า และสมาชิก" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index d7b56d7021..4577a23154 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Sözleşmeler" -"Financial reports" = "Finansal raporlar" -"HR records" = "İK kayıtları" -Insurance = "Sigorta" -Invoices = "Faturalar" -"Legal filings" = "Yasal başvurular" -"Medical / PHI" = "Tıbbi / PHI" -"Tax documents" = "Vergi belgeleri" - [policies.enforcement] applying = "{{names}} uygulanıyor" applyingProgress = "{{names}} uygulanıyor ({{done}} / {{total}})" @@ -6025,51 +6015,6 @@ notify = "Yönlendirmede bildir" onViolation = "Uyumsuz olduğunda" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 yıl" -"30 days" = "30 gün" -"90 days" = "90 gün" -Never = "Asla" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "İnceleme için işaretle" -Hold = "Tut" -"Route to bucket" = "Bucket'a yönlendir" - -[policies.fieldOption.destination] -Documents = "Belgeler" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 yıl" -"3 years" = "3 yıl" -"30 days" = "30 gün" -"7 years" = "7 yıl" -Indefinite = "Süresiz" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "PHI'yi otomatik sansürle" -"Block export" = "Dışa aktarmayı engelle" -"Flag for review" = "İnceleme için işaretle" -"Quarantine document" = "Belgeyi karantinaya al" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Katmanı kapat" enforcingTitle = "Politika uygulanıyor..." viewAnyway = "Dosyayı görüntüle (politika hâlâ uygulanıyor)" +[policyOption] +autoRedactPhi = "PHI'yi otomatik sansürle" +blockExport = "Dışa aktarmayı engelle" +contracts = "Sözleşmeler" +documents = "Belgeler" +fedramp = "FedRAMP" +financialReports = "Finansal raporlar" +flagForReview = "İnceleme için işaretle" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Tut" +hrRecords = "İK kayıtları" +indefinite = "Süresiz" +insurance = "Sigorta" +invoices = "Faturalar" +iso27001 = "ISO 27001" +legalFilings = "Yasal başvurular" +medicalPhi = "Tıbbi / PHI" +never = "Asla" +ninetyDays = "90 gün" +oneYear = "1 yıl" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Belgeyi karantinaya al" +routeToBucket = "Bucket'a yönlendir" +s3Bucket = "S3 bucket" +sevenYears = "7 yıl" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Vergi belgeleri" +thirtyDays = "30 gün" +threeYears = "3 yıl" +webhook = "Webhook" + [portal] comingSoon = "Yakında" @@ -8067,10 +8050,6 @@ title = "Oturum süresi doldu" [portal.users] title = "Kullanıcılar" -[portal.users.roles] -subtitle = "Her rol her planda bulunur — her birinin yapabilecekleri kuruluş genelinde sabittir." -title = "Roller" - [portal.users.roles.admin] label = "Yönetici (Kuruluş sahibi)" summary = "Çalışma alanı, ayarlar ve üyeler üzerinde tam yönetişim." diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index 18669c7b19..d0cdcc91d5 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Договори" -"Financial reports" = "Фінансові звіти" -"HR records" = "Кадрові записи" -Insurance = "Страхування" -Invoices = "Рахунки-фактури" -"Legal filings" = "Юридичні подання" -"Medical / PHI" = "Медичні / PHI" -"Tax documents" = "Податкові документи" - [policies.enforcement] applying = "Застосовуємо {{names}}" applyingProgress = "Застосовуємо {{names}} ({{done}} з {{total}})" @@ -6025,51 +6015,6 @@ notify = "Сповіщати під час маршрутизації" onViolation = "У разі невідповідності" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 рік" -"30 days" = "30 днів" -"90 days" = "90 днів" -Never = "Ніколи" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Позначити для перевірки" -Hold = "Утримати" -"Route to bucket" = "Маршрутизувати в bucket" - -[policies.fieldOption.destination] -Documents = "Документи" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 рік" -"3 years" = "3 роки" -"30 days" = "30 днів" -"7 years" = "7 років" -Indefinite = "Безстроково" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Автоматично приховати PHI" -"Block export" = "Заблокувати експорт" -"Flag for review" = "Позначити для перевірки" -"Quarantine document" = "Помістити документ у карантин" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Закрити накладення" enforcingTitle = "Застосування політики..." viewAnyway = "Переглянути файл (політика ще застосовується)" +[policyOption] +autoRedactPhi = "Автоматично приховати PHI" +blockExport = "Заблокувати експорт" +contracts = "Договори" +documents = "Документи" +fedramp = "FedRAMP" +financialReports = "Фінансові звіти" +flagForReview = "Позначити для перевірки" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Утримати" +hrRecords = "Кадрові записи" +indefinite = "Безстроково" +insurance = "Страхування" +invoices = "Рахунки-фактури" +iso27001 = "ISO 27001" +legalFilings = "Юридичні подання" +medicalPhi = "Медичні / PHI" +never = "Ніколи" +ninetyDays = "90 днів" +oneYear = "1 рік" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Помістити документ у карантин" +routeToBucket = "Маршрутизувати в bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 років" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Податкові документи" +thirtyDays = "30 днів" +threeYears = "3 роки" +webhook = "Webhook" + [portal] comingSoon = "Незабаром" @@ -8067,10 +8050,6 @@ title = "Сеанс закінчився" [portal.users] title = "Користувачі" -[portal.users.roles] -subtitle = "Кожна роль існує в кожному плані — те, що кожна може робити, фіксовано для всієї організації." -title = "Ролі" - [portal.users.roles.admin] label = "Адміністратор (власник організації)" summary = "Повне керування робочим простором, налаштуваннями та учасниками." diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index 8edc557e3a..18afcd310f 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "Hợp đồng" -"Financial reports" = "Báo cáo tài chính" -"HR records" = "Hồ sơ nhân sự" -Insurance = "Bảo hiểm" -Invoices = "Hóa đơn" -"Legal filings" = "Hồ sơ pháp lý" -"Medical / PHI" = "Y tế / PHI" -"Tax documents" = "Tài liệu thuế" - [policies.enforcement] applying = "Đang áp dụng {{names}}" applyingProgress = "Đang áp dụng {{names}} ({{done}} trong {{total}})" @@ -6025,51 +6015,6 @@ notify = "Thông báo khi định tuyến" onViolation = "Khi không tuân thủ" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 năm" -"30 days" = "30 ngày" -"90 days" = "90 ngày" -Never = "Không bao giờ" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "Đánh dấu để xem xét" -Hold = "Giữ lại" -"Route to bucket" = "Định tuyến đến bucket" - -[policies.fieldOption.destination] -Documents = "Tài liệu" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 năm" -"3 years" = "3 năm" -"30 days" = "30 ngày" -"7 years" = "7 năm" -Indefinite = "Vô thời hạn" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "Tự động che PHI" -"Block export" = "Chặn xuất" -"Flag for review" = "Đánh dấu để xem xét" -"Quarantine document" = "Cách ly tài liệu" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "Đóng lớp phủ" enforcingTitle = "Đang thực thi chính sách..." viewAnyway = "Xem tệp (chính sách vẫn đang thực thi)" +[policyOption] +autoRedactPhi = "Tự động che PHI" +blockExport = "Chặn xuất" +contracts = "Hợp đồng" +documents = "Tài liệu" +fedramp = "FedRAMP" +financialReports = "Báo cáo tài chính" +flagForReview = "Đánh dấu để xem xét" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "Giữ lại" +hrRecords = "Hồ sơ nhân sự" +indefinite = "Vô thời hạn" +insurance = "Bảo hiểm" +invoices = "Hóa đơn" +iso27001 = "ISO 27001" +legalFilings = "Hồ sơ pháp lý" +medicalPhi = "Y tế / PHI" +never = "Không bao giờ" +ninetyDays = "90 ngày" +oneYear = "1 năm" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "Cách ly tài liệu" +routeToBucket = "Định tuyến đến bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 năm" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "Tài liệu thuế" +thirtyDays = "30 ngày" +threeYears = "3 năm" +webhook = "Webhook" + [portal] comingSoon = "Sắp ra mắt" @@ -8067,10 +8050,6 @@ title = "Phiên đã hết hạn" [portal.users] title = "Người dùng" -[portal.users.roles] -subtitle = "Mọi vai trò tồn tại trên mọi gói — những gì mỗi vai trò có thể làm là cố định trên toàn tổ chức." -title = "Vai trò" - [portal.users.roles.admin] label = "Quản trị viên (Chủ sở hữu tổ chức)" summary = "Toàn quyền quản trị workspace, cài đặt và thành viên." diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 3a224f3633..836971b555 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "合同" -"Financial reports" = "财务报告" -"HR records" = "HR 记录" -Insurance = "保险" -Invoices = "发票" -"Legal filings" = "法律申报文件" -"Medical / PHI" = "医疗 / PHI" -"Tax documents" = "税务文件" - [policies.enforcement] applying = "正在应用 {{names}}" applyingProgress = "正在应用 {{names}}({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "路由时通知" onViolation = "不合规时" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 年" -"30 days" = "30 天" -"90 days" = "90 天" -Never = "从不" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "标记以供审核" -Hold = "保留" -"Route to bucket" = "路由到存储桶" - -[policies.fieldOption.destination] -Documents = "文档" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 年" -"3 years" = "3 年" -"30 days" = "30 天" -"7 years" = "7 年" -Indefinite = "无限期" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "自动遮盖 PHI" -"Block export" = "阻止导出" -"Flag for review" = "标记以供审核" -"Quarantine document" = "隔离文档" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "关闭覆盖层" enforcingTitle = "正在执行策略..." viewAnyway = "查看文件(策略仍在执行)" +[policyOption] +autoRedactPhi = "自动遮盖 PHI" +blockExport = "阻止导出" +contracts = "合同" +documents = "文档" +fedramp = "FedRAMP" +financialReports = "财务报告" +flagForReview = "标记以供审核" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "保留" +hrRecords = "HR 记录" +indefinite = "无限期" +insurance = "保险" +invoices = "发票" +iso27001 = "ISO 27001" +legalFilings = "法律申报文件" +medicalPhi = "医疗 / PHI" +never = "从不" +ninetyDays = "90 天" +oneYear = "1 年" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "隔离文档" +routeToBucket = "路由到存储桶" +s3Bucket = "S3 bucket" +sevenYears = "7 年" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "税务文件" +thirtyDays = "30 天" +threeYears = "3 年" +webhook = "Webhook" + [portal] comingSoon = "即将推出" @@ -8067,10 +8050,6 @@ title = "会话已过期" [portal.users] title = "用户" -[portal.users.roles] -subtitle = "每个方案都有所有角色——每个角色能做什么在整个组织中固定。" -title = "角色" - [portal.users.roles.admin] label = "管理员(组织所有者)" summary = "对工作区、设置和成员拥有完整治理权限。" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index d81be6405a..ef6ff55a7b 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "合同" -"Financial reports" = "财务报告" -"HR records" = "人力资源记录" -Insurance = "保险" -Invoices = "发票" -"Legal filings" = "法律申报文件" -"Medical / PHI" = "医疗 / PHI" -"Tax documents" = "税务文件" - [policies.enforcement] applying = "正在应用 {{names}}" applyingProgress = "正在应用 {{names}} ({{done}}/{{total}})" @@ -6025,51 +6015,6 @@ notify = "路由时通知" onViolation = "不合规时" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 年" -"30 days" = "30 天" -"90 days" = "90 天" -Never = "从不" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "标记以供审核" -Hold = "保留" -"Route to bucket" = "路由到存储桶" - -[policies.fieldOption.destination] -Documents = "文档" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 年" -"3 years" = "3 年" -"30 days" = "30 天" -"7 years" = "7 年" -Indefinite = "无限期" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "自动遮盖 PHI" -"Block export" = "阻止导出" -"Flag for review" = "标记以供审核" -"Quarantine document" = "隔离文档" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "关闭叠加层" enforcingTitle = "正在强制执行策略..." viewAnyway = "查看文件(策略仍在强制执行)" +[policyOption] +autoRedactPhi = "自动遮盖 PHI" +blockExport = "阻止导出" +contracts = "合同" +documents = "文档" +fedramp = "FedRAMP" +financialReports = "财务报告" +flagForReview = "标记以供审核" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "保留" +hrRecords = "人力资源记录" +indefinite = "无限期" +insurance = "保险" +invoices = "发票" +iso27001 = "ISO 27001" +legalFilings = "法律申报文件" +medicalPhi = "医疗 / PHI" +never = "从不" +ninetyDays = "90 天" +oneYear = "1 年" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "隔离文档" +routeToBucket = "路由到存储桶" +s3Bucket = "S3 bucket" +sevenYears = "7 年" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "税务文件" +thirtyDays = "30 天" +threeYears = "3 年" +webhook = "Webhook" + [portal] comingSoon = "即将推出" @@ -8067,10 +8050,6 @@ title = "会话已过期" [portal.users] title = "用户" -[portal.users.roles] -subtitle = "每个方案都有每个角色——每个角色可执行的操作在整个组织中固定。" -title = "角色" - [portal.users.roles.admin] label = "管理员(组织所有者)" summary = "对工作区、设置和成员拥有完整治理权限。" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 98194e6c9a..c0f9e5fe18 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -5980,16 +5980,6 @@ statDocsEnforced = "Docs enforced" statusActive = "Active" statusPaused = "Paused" -[policies.docType] -Contracts = "合約" -"Financial reports" = "財務報告" -"HR records" = "人資紀錄" -Insurance = "保險" -Invoices = "發票" -"Legal filings" = "法律申報文件" -"Medical / PHI" = "醫療 / PHI" -"Tax documents" = "稅務文件" - [policies.enforcement] applying = "正在套用 {{names}}" applyingProgress = "正在套用 {{names}}({{done}} / {{total}})" @@ -6025,51 +6015,6 @@ notify = "路由時通知" onViolation = "不符合規範時" webhookUrl = "Webhook URL" -[policies.fieldOption.archiveAfter] -"1 year" = "1 年" -"30 days" = "30 天" -"90 days" = "90 天" -Never = "永不" - -[policies.fieldOption.belowThreshold] -"Flag for review" = "標記以供審查" -Hold = "保留" -"Route to bucket" = "路由到 bucket" - -[policies.fieldOption.destination] -Documents = "文件" -"S3 bucket" = "S3 bucket" -SharePoint = "SharePoint" -Webhook = "Webhook" - -[policies.fieldOption.frameworks] -FedRAMP = "FedRAMP" -GDPR = "GDPR" -HIPAA = "HIPAA" -"ISO 27001" = "ISO 27001" -"PCI DSS" = "PCI DSS" -"SOC 2" = "SOC 2" - -[policies.fieldOption.keepFor] -"1 year" = "1 年" -"3 years" = "3 年" -"30 days" = "30 天" -"7 years" = "7 年" -Indefinite = "無限期" - -[policies.fieldOption.minConfidence] -"60%" = "60%" -"70%" = "70%" -"80%" = "80%" -"90%" = "90%" -"95%" = "95%" - -[policies.fieldOption.onViolation] -"Auto-redact PHI" = "自動遮蔽 PHI" -"Block export" = "封鎖匯出" -"Flag for review" = "標記以供審查" -"Quarantine document" = "隔離文件" - [policies.fields] selectedCount = "{{count}} selected" @@ -6203,6 +6148,44 @@ dismiss = "關閉覆蓋層" enforcingTitle = "正在強制執行政策..." viewAnyway = "檢視檔案(政策仍在強制執行)" +[policyOption] +autoRedactPhi = "自動遮蔽 PHI" +blockExport = "封鎖匯出" +contracts = "合約" +documents = "文件" +fedramp = "FedRAMP" +financialReports = "財務報告" +flagForReview = "標記以供審查" +gdpr = "GDPR" +hipaa = "HIPAA" +hold = "保留" +hrRecords = "人資紀錄" +indefinite = "無限期" +insurance = "保險" +invoices = "發票" +iso27001 = "ISO 27001" +legalFilings = "法律申報文件" +medicalPhi = "醫療 / PHI" +never = "永不" +ninetyDays = "90 天" +oneYear = "1 年" +p60 = "60%" +p70 = "70%" +p80 = "80%" +p90 = "90%" +p95 = "95%" +pciDss = "PCI DSS" +quarantineDocument = "隔離文件" +routeToBucket = "路由到 bucket" +s3Bucket = "S3 bucket" +sevenYears = "7 年" +sharePoint = "SharePoint" +soc2 = "SOC 2" +taxDocuments = "稅務文件" +thirtyDays = "30 天" +threeYears = "3 年" +webhook = "Webhook" + [portal] comingSoon = "即將推出" @@ -8067,10 +8050,6 @@ title = "工作階段已過期" [portal.users] title = "使用者" -[portal.users.roles] -subtitle = "每個方案都有所有角色 — 各角色可執行的動作在整個組織中固定一致。" -title = "角色" - [portal.users.roles.admin] label = "管理員(組織擁有者)" summary = "對工作區、設定和成員擁有完整治理權限。" diff --git a/frontend/editor/public/og_images/add-attachments.png b/frontend/editor/public/og_images/add-attachments.png index 15b1d53973..98a8aecfd5 100644 Binary files a/frontend/editor/public/og_images/add-attachments.png and b/frontend/editor/public/og_images/add-attachments.png differ diff --git a/frontend/editor/public/og_images/add-image.png b/frontend/editor/public/og_images/add-image.png index 84c5dd81a7..fe141f7d80 100644 Binary files a/frontend/editor/public/og_images/add-image.png and b/frontend/editor/public/og_images/add-image.png differ diff --git a/frontend/editor/public/og_images/add-page-numbers.png b/frontend/editor/public/og_images/add-page-numbers.png index 17afd0d0ce..5d2394bcd5 100644 Binary files a/frontend/editor/public/og_images/add-page-numbers.png and b/frontend/editor/public/og_images/add-page-numbers.png differ diff --git a/frontend/editor/public/og_images/add-password.png b/frontend/editor/public/og_images/add-password.png index ea55912017..e3f6214380 100644 Binary files a/frontend/editor/public/og_images/add-password.png and b/frontend/editor/public/og_images/add-password.png differ diff --git a/frontend/editor/public/og_images/add-stamp.png b/frontend/editor/public/og_images/add-stamp.png index d5fb068214..028e62fecd 100644 Binary files a/frontend/editor/public/og_images/add-stamp.png and b/frontend/editor/public/og_images/add-stamp.png differ diff --git a/frontend/editor/public/og_images/add-text.png b/frontend/editor/public/og_images/add-text.png index 00a3a4af13..503dbac082 100644 Binary files a/frontend/editor/public/og_images/add-text.png and b/frontend/editor/public/og_images/add-text.png differ diff --git a/frontend/editor/public/og_images/add-watermark.png b/frontend/editor/public/og_images/add-watermark.png index 9e862a1b3d..19466d332b 100644 Binary files a/frontend/editor/public/og_images/add-watermark.png and b/frontend/editor/public/og_images/add-watermark.png differ diff --git a/frontend/editor/public/og_images/adjust-colors-contrast.png b/frontend/editor/public/og_images/adjust-colors-contrast.png index 37d2ad29da..c025d14d01 100644 Binary files a/frontend/editor/public/og_images/adjust-colors-contrast.png and b/frontend/editor/public/og_images/adjust-colors-contrast.png differ diff --git a/frontend/editor/public/og_images/adjust-page-size-scale.png b/frontend/editor/public/og_images/adjust-page-size-scale.png index b88937f5aa..f7818a2698 100644 Binary files a/frontend/editor/public/og_images/adjust-page-size-scale.png and b/frontend/editor/public/og_images/adjust-page-size-scale.png differ diff --git a/frontend/editor/public/og_images/annotate.png b/frontend/editor/public/og_images/annotate.png index 9049094183..3910197cdb 100644 Binary files a/frontend/editor/public/og_images/annotate.png and b/frontend/editor/public/og_images/annotate.png differ diff --git a/frontend/editor/public/og_images/auto-rename-pdf-file.png b/frontend/editor/public/og_images/auto-rename-pdf-file.png index 5a4e2d3caa..d8bd7d5102 100644 Binary files a/frontend/editor/public/og_images/auto-rename-pdf-file.png and b/frontend/editor/public/og_images/auto-rename-pdf-file.png differ diff --git a/frontend/editor/public/og_images/automate.png b/frontend/editor/public/og_images/automate.png index b30d5f14ea..e44c33ea9e 100644 Binary files a/frontend/editor/public/og_images/automate.png and b/frontend/editor/public/og_images/automate.png differ diff --git a/frontend/editor/public/og_images/booklet-imposition.png b/frontend/editor/public/og_images/booklet-imposition.png index ff9989d61d..79ba6c7fd0 100644 Binary files a/frontend/editor/public/og_images/booklet-imposition.png and b/frontend/editor/public/og_images/booklet-imposition.png differ diff --git a/frontend/editor/public/og_images/certSign.png b/frontend/editor/public/og_images/certSign.png index c1ac1629cb..0160f43a52 100644 Binary files a/frontend/editor/public/og_images/certSign.png and b/frontend/editor/public/og_images/certSign.png differ diff --git a/frontend/editor/public/og_images/change-metadata.png b/frontend/editor/public/og_images/change-metadata.png index 856fe4ef98..989be40cb6 100644 Binary files a/frontend/editor/public/og_images/change-metadata.png and b/frontend/editor/public/og_images/change-metadata.png differ diff --git a/frontend/editor/public/og_images/change-permissions.png b/frontend/editor/public/og_images/change-permissions.png index d19d85c368..c22879514a 100644 Binary files a/frontend/editor/public/og_images/change-permissions.png and b/frontend/editor/public/og_images/change-permissions.png differ diff --git a/frontend/editor/public/og_images/compare.png b/frontend/editor/public/og_images/compare.png index 180c04c301..ba7a48879b 100644 Binary files a/frontend/editor/public/og_images/compare.png and b/frontend/editor/public/og_images/compare.png differ diff --git a/frontend/editor/public/og_images/compress.png b/frontend/editor/public/og_images/compress.png index 790573f9cb..119fc884d1 100644 Binary files a/frontend/editor/public/og_images/compress.png and b/frontend/editor/public/og_images/compress.png differ diff --git a/frontend/editor/public/og_images/convert.png b/frontend/editor/public/og_images/convert.png index 1453655087..1740d97067 100644 Binary files a/frontend/editor/public/og_images/convert.png and b/frontend/editor/public/og_images/convert.png differ diff --git a/frontend/editor/public/og_images/cropPdf.png b/frontend/editor/public/og_images/cropPdf.png index 3c2bbe55b7..8973f0da5b 100644 Binary files a/frontend/editor/public/og_images/cropPdf.png and b/frontend/editor/public/og_images/cropPdf.png differ diff --git a/frontend/editor/public/og_images/detect-split-scanned-photos.png b/frontend/editor/public/og_images/detect-split-scanned-photos.png index 029481db18..65a6f5e84e 100644 Binary files a/frontend/editor/public/og_images/detect-split-scanned-photos.png and b/frontend/editor/public/og_images/detect-split-scanned-photos.png differ diff --git a/frontend/editor/public/og_images/dev-airgapped.png b/frontend/editor/public/og_images/dev-airgapped.png index 3e5f93f9b7..597bc6727f 100644 Binary files a/frontend/editor/public/og_images/dev-airgapped.png and b/frontend/editor/public/og_images/dev-airgapped.png differ diff --git a/frontend/editor/public/og_images/dev-api.png b/frontend/editor/public/og_images/dev-api.png index f422853199..d44353115c 100644 Binary files a/frontend/editor/public/og_images/dev-api.png and b/frontend/editor/public/og_images/dev-api.png differ diff --git a/frontend/editor/public/og_images/dev-folder-scanning.png b/frontend/editor/public/og_images/dev-folder-scanning.png index 0a25e9b42a..011d48c97d 100644 Binary files a/frontend/editor/public/og_images/dev-folder-scanning.png and b/frontend/editor/public/og_images/dev-folder-scanning.png differ diff --git a/frontend/editor/public/og_images/dev-sso-guide.png b/frontend/editor/public/og_images/dev-sso-guide.png index e03dfb31f9..c0e879a439 100644 Binary files a/frontend/editor/public/og_images/dev-sso-guide.png and b/frontend/editor/public/og_images/dev-sso-guide.png differ diff --git a/frontend/editor/public/og_images/edit-table-of-contents.png b/frontend/editor/public/og_images/edit-table-of-contents.png index bf78ab8735..e056de1a94 100644 Binary files a/frontend/editor/public/og_images/edit-table-of-contents.png and b/frontend/editor/public/og_images/edit-table-of-contents.png differ diff --git a/frontend/editor/public/og_images/extract-images.png b/frontend/editor/public/og_images/extract-images.png index 7c4ce07bb5..3376cc4b66 100644 Binary files a/frontend/editor/public/og_images/extract-images.png and b/frontend/editor/public/og_images/extract-images.png differ diff --git a/frontend/editor/public/og_images/extract-pages.png b/frontend/editor/public/og_images/extract-pages.png index ddf38f272b..590beaf11c 100644 Binary files a/frontend/editor/public/og_images/extract-pages.png and b/frontend/editor/public/og_images/extract-pages.png differ diff --git a/frontend/editor/public/og_images/flatten.png b/frontend/editor/public/og_images/flatten.png index a6f2180dee..58144ff014 100644 Binary files a/frontend/editor/public/og_images/flatten.png and b/frontend/editor/public/og_images/flatten.png differ diff --git a/frontend/editor/public/og_images/form-fill.png b/frontend/editor/public/og_images/form-fill.png index 040f4d3ea7..7f50084c83 100644 Binary files a/frontend/editor/public/og_images/form-fill.png and b/frontend/editor/public/og_images/form-fill.png differ diff --git a/frontend/editor/public/og_images/get-all-info-on-pdf.png b/frontend/editor/public/og_images/get-all-info-on-pdf.png index ff5c4d7ab6..a9cf7c9091 100644 Binary files a/frontend/editor/public/og_images/get-all-info-on-pdf.png and b/frontend/editor/public/og_images/get-all-info-on-pdf.png differ diff --git a/frontend/editor/public/og_images/home.png b/frontend/editor/public/og_images/home.png index 2fd04b54c4..763243ff27 100644 Binary files a/frontend/editor/public/og_images/home.png and b/frontend/editor/public/og_images/home.png differ diff --git a/frontend/editor/public/og_images/mergePdfs.png b/frontend/editor/public/og_images/mergePdfs.png index 7825847f1a..47d4c3f784 100644 Binary files a/frontend/editor/public/og_images/mergePdfs.png and b/frontend/editor/public/og_images/mergePdfs.png differ diff --git a/frontend/editor/public/og_images/multi-page-layout.png b/frontend/editor/public/og_images/multi-page-layout.png index 49ec9cee64..b5fb54938e 100644 Binary files a/frontend/editor/public/og_images/multi-page-layout.png and b/frontend/editor/public/og_images/multi-page-layout.png differ diff --git a/frontend/editor/public/og_images/multi-tool.png b/frontend/editor/public/og_images/multi-tool.png index d3cbc8af5c..71db3a41a5 100644 Binary files a/frontend/editor/public/og_images/multi-tool.png and b/frontend/editor/public/og_images/multi-tool.png differ diff --git a/frontend/editor/public/og_images/ocr.png b/frontend/editor/public/og_images/ocr.png index 632bfa7f7d..5cbdef5533 100644 Binary files a/frontend/editor/public/og_images/ocr.png and b/frontend/editor/public/og_images/ocr.png differ diff --git a/frontend/editor/public/og_images/overlay-pdfs.png b/frontend/editor/public/og_images/overlay-pdfs.png index af9ab8d478..30a8541c8b 100644 Binary files a/frontend/editor/public/og_images/overlay-pdfs.png and b/frontend/editor/public/og_images/overlay-pdfs.png differ diff --git a/frontend/editor/public/og_images/pdf-text-editor.png b/frontend/editor/public/og_images/pdf-text-editor.png index 310ee81c5e..b4715fcc25 100644 Binary files a/frontend/editor/public/og_images/pdf-text-editor.png and b/frontend/editor/public/og_images/pdf-text-editor.png differ diff --git a/frontend/editor/public/og_images/read.png b/frontend/editor/public/og_images/read.png index 4965399f9d..55b4e2df0a 100644 Binary files a/frontend/editor/public/og_images/read.png and b/frontend/editor/public/og_images/read.png differ diff --git a/frontend/editor/public/og_images/redact.png b/frontend/editor/public/og_images/redact.png index 3f3e81d123..e80aba7a42 100644 Binary files a/frontend/editor/public/og_images/redact.png and b/frontend/editor/public/og_images/redact.png differ diff --git a/frontend/editor/public/og_images/remove-annotations.png b/frontend/editor/public/og_images/remove-annotations.png index d047cd10dc..b3631b052b 100644 Binary files a/frontend/editor/public/og_images/remove-annotations.png and b/frontend/editor/public/og_images/remove-annotations.png differ diff --git a/frontend/editor/public/og_images/remove-blank-pages.png b/frontend/editor/public/og_images/remove-blank-pages.png index ec1dec25c7..49005a517f 100644 Binary files a/frontend/editor/public/og_images/remove-blank-pages.png and b/frontend/editor/public/og_images/remove-blank-pages.png differ diff --git a/frontend/editor/public/og_images/remove-certificate-sign.png b/frontend/editor/public/og_images/remove-certificate-sign.png index 80e03c6030..e5988d8850 100644 Binary files a/frontend/editor/public/og_images/remove-certificate-sign.png and b/frontend/editor/public/og_images/remove-certificate-sign.png differ diff --git a/frontend/editor/public/og_images/remove-image.png b/frontend/editor/public/og_images/remove-image.png index 2b49fd51d8..8a2de0d304 100644 Binary files a/frontend/editor/public/og_images/remove-image.png and b/frontend/editor/public/og_images/remove-image.png differ diff --git a/frontend/editor/public/og_images/remove-password.png b/frontend/editor/public/og_images/remove-password.png index 91706dd893..c023251375 100644 Binary files a/frontend/editor/public/og_images/remove-password.png and b/frontend/editor/public/og_images/remove-password.png differ diff --git a/frontend/editor/public/og_images/remove.png b/frontend/editor/public/og_images/remove.png index 5836f18c9f..649ca2dd9a 100644 Binary files a/frontend/editor/public/og_images/remove.png and b/frontend/editor/public/og_images/remove.png differ diff --git a/frontend/editor/public/og_images/reorganize-pages.png b/frontend/editor/public/og_images/reorganize-pages.png index 3034265ba4..ce7a3d40eb 100644 Binary files a/frontend/editor/public/og_images/reorganize-pages.png and b/frontend/editor/public/og_images/reorganize-pages.png differ diff --git a/frontend/editor/public/og_images/repair.png b/frontend/editor/public/og_images/repair.png index f7043becc8..31bc026cb5 100644 Binary files a/frontend/editor/public/og_images/repair.png and b/frontend/editor/public/og_images/repair.png differ diff --git a/frontend/editor/public/og_images/replace-and-invert-color.png b/frontend/editor/public/og_images/replace-and-invert-color.png index 391202b846..4d9359876d 100644 Binary files a/frontend/editor/public/og_images/replace-and-invert-color.png and b/frontend/editor/public/og_images/replace-and-invert-color.png differ diff --git a/frontend/editor/public/og_images/rotate.png b/frontend/editor/public/og_images/rotate.png index 7c2b2d2d15..54f05046b2 100644 Binary files a/frontend/editor/public/og_images/rotate.png and b/frontend/editor/public/og_images/rotate.png differ diff --git a/frontend/editor/public/og_images/sanitize.png b/frontend/editor/public/og_images/sanitize.png index 2c7ffb1b78..a8a3737af2 100644 Binary files a/frontend/editor/public/og_images/sanitize.png and b/frontend/editor/public/og_images/sanitize.png differ diff --git a/frontend/editor/public/og_images/scanner-effect.png b/frontend/editor/public/og_images/scanner-effect.png index ab269b9e90..c6c5581ace 100644 Binary files a/frontend/editor/public/og_images/scanner-effect.png and b/frontend/editor/public/og_images/scanner-effect.png differ diff --git a/frontend/editor/public/og_images/show-javascript.png b/frontend/editor/public/og_images/show-javascript.png index 0c98c3ce30..c8df270948 100644 Binary files a/frontend/editor/public/og_images/show-javascript.png and b/frontend/editor/public/og_images/show-javascript.png differ diff --git a/frontend/editor/public/og_images/sign.png b/frontend/editor/public/og_images/sign.png index 6b2b136c60..813a251210 100644 Binary files a/frontend/editor/public/og_images/sign.png and b/frontend/editor/public/og_images/sign.png differ diff --git a/frontend/editor/public/og_images/single-large-page.png b/frontend/editor/public/og_images/single-large-page.png index 9d78889d60..b03a3cd5c6 100644 Binary files a/frontend/editor/public/og_images/single-large-page.png and b/frontend/editor/public/og_images/single-large-page.png differ diff --git a/frontend/editor/public/og_images/split.png b/frontend/editor/public/og_images/split.png index ab5d6a96fb..15665b6bbb 100644 Binary files a/frontend/editor/public/og_images/split.png and b/frontend/editor/public/og_images/split.png differ diff --git a/frontend/editor/public/og_images/timestamp-pdf.png b/frontend/editor/public/og_images/timestamp-pdf.png index a11c62fa18..8d28570740 100644 Binary files a/frontend/editor/public/og_images/timestamp-pdf.png and b/frontend/editor/public/og_images/timestamp-pdf.png differ diff --git a/frontend/editor/public/og_images/unlock-pdf-forms.png b/frontend/editor/public/og_images/unlock-pdf-forms.png index 620a45910d..6ed6f8c28d 100644 Binary files a/frontend/editor/public/og_images/unlock-pdf-forms.png and b/frontend/editor/public/og_images/unlock-pdf-forms.png differ diff --git a/frontend/editor/public/og_images/validate-pdf-signature.png b/frontend/editor/public/og_images/validate-pdf-signature.png index f4b141bf44..6d2abb8ef0 100644 Binary files a/frontend/editor/public/og_images/validate-pdf-signature.png and b/frontend/editor/public/og_images/validate-pdf-signature.png differ diff --git a/frontend/editor/scripts/og-assets/stirling-lockup.png b/frontend/editor/scripts/og-assets/stirling-lockup.png index d34075798e..cfe2037494 100644 Binary files a/frontend/editor/scripts/og-assets/stirling-lockup.png and b/frontend/editor/scripts/og-assets/stirling-lockup.png differ diff --git a/frontend/editor/src-tauri/icons/128x128@2x.png b/frontend/editor/src-tauri/icons/128x128@2x.png index 0ac88b8db7..9473eda3f1 100644 Binary files a/frontend/editor/src-tauri/icons/128x128@2x.png and b/frontend/editor/src-tauri/icons/128x128@2x.png differ diff --git a/frontend/editor/src-tauri/icons/Square142x142Logo.png b/frontend/editor/src-tauri/icons/Square142x142Logo.png index aa8b405762..3a74337ff0 100644 Binary files a/frontend/editor/src-tauri/icons/Square142x142Logo.png and b/frontend/editor/src-tauri/icons/Square142x142Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square150x150Logo.png b/frontend/editor/src-tauri/icons/Square150x150Logo.png index 3c33180f9f..7efa08a3ae 100644 Binary files a/frontend/editor/src-tauri/icons/Square150x150Logo.png and b/frontend/editor/src-tauri/icons/Square150x150Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square284x284Logo.png b/frontend/editor/src-tauri/icons/Square284x284Logo.png index 72c5d71883..3138c510c9 100644 Binary files a/frontend/editor/src-tauri/icons/Square284x284Logo.png and b/frontend/editor/src-tauri/icons/Square284x284Logo.png differ diff --git a/frontend/editor/src-tauri/icons/Square310x310Logo.png b/frontend/editor/src-tauri/icons/Square310x310Logo.png index b5fa0be56b..a38038f8cc 100644 Binary files a/frontend/editor/src-tauri/icons/Square310x310Logo.png and b/frontend/editor/src-tauri/icons/Square310x310Logo.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 475ea7dbc5..dd48655a9b 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index c0cf472deb..9b7807975c 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index b5df806c1a..95a8327d0c 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 21a8c5bfb6..6b0bf8f45e 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index b5df806c1a..95a8327d0c 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 224b0169ca..e6603a9268 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 516f2024e2..503572a657 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 224b0169ca..e6603a9268 100644 Binary files a/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/frontend/editor/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png index d1d0ee3683..1648f9cc90 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png index 346e4a702e..e75780f7ff 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png index d1d0ee3683..1648f9cc90 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png index 5190cb21b9..53ff5b05dc 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png index 4085fefa31..9df1bcea80 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 2ad07a090f..6403edcfee 100644 Binary files a/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/frontend/editor/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx index 5aa52034a4..48d2b3087f 100644 --- a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx @@ -1,15 +1,16 @@ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded"; import { useTranslation } from "react-i18next"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { renderButtons } from "@app/components/onboarding/renderButtons"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import { + type SlideId, + type ButtonAction, + type ButtonDefinition, +} from "@app/components/onboarding/saasOnboardingFlowConfig"; interface SaasOnboardingModalProps { opened: boolean; @@ -20,6 +21,7 @@ interface SaasOnboardingModalProps { * false (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { @@ -39,130 +41,45 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { handleButtonAction, } = flow; - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -

-
- Stirling icon -
-
- ); - } - - if (slideDefinition.hero.type === "logo") { - return ( - Stirling logo - ); - } - - return ( -
- {slideDefinition.hero.type === "bolt" && ( - - )} - {slideDefinition.hero.type === "team" && ( - - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "bolt" && } + {heroType === "team" && } + ); + + const resolveLabel = (button: ButtonDefinition) => { + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - handleButtonAction(action as ButtonAction)} onClose={props.onClose} - closeOnClickOutside={false} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0 }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - display: "flex", - flexDirection: "column", - }, - }} - > - -
- -
- {renderHero()} -
-
- -
- -
- {currentSlide.title} -
- -
-
- {currentSlide.body} -
- -
- - - -
- {renderButtons({ - slideDefinition, - flowState, - onAction: handleButtonAction, - t, - })} -
-
-
-
-
+ /> ); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts index b6fb199730..436a9b4bba 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts @@ -1,4 +1,8 @@ import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; +import { + resolveFlowIds, + type FlowStep, +} from "@app/components/onboarding/onboardingSlideTypes"; export interface SaasFlowInputs { /** Free-tier wallet with one-time allowance remaining — show the usage meter. */ @@ -13,22 +17,23 @@ export interface SaasFlowInputs { hideDesktopInstall?: boolean; } +// The SaaS flow as data: free-editor and desktop-install bookend it; the usage +// meter and team slides slot in when their conditions hold. This is the same +// "flow = the steps that apply, in order" shape the core flow uses, resolved +// through the shared {@link resolveFlowIds} helper. +const SAAS_FLOW: FlowStep[] = [ + { id: "free-editor", when: () => true }, + { id: "usage", when: (input) => input.showUsageSlide }, + { id: "team", when: (input) => input.showTeamSlide }, + { id: "desktop-install", when: (input) => !input.hideDesktopInstall }, +]; + /** - * Resolves the SaaS onboarding slide sequence. The free-editor pitch and - * desktop install bookend the flow; the usage meter and team slides slot in - * when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is - * set, the closing desktop-install slide is dropped (used by the desktop app, - * which has no reason to pitch its own download). + * Resolves the SaaS onboarding slide sequence. When + * {@link SaasFlowInputs.hideDesktopInstall} is set, the closing desktop-install + * slide is dropped (used by the desktop app, which has no reason to pitch its + * own download). */ -export function resolveSaasFlow({ - showUsageSlide, - showTeamSlide, - hideDesktopInstall = false, -}: SaasFlowInputs): SlideId[] { - return [ - "free-editor", - ...(showUsageSlide ? (["usage"] as const) : []), - ...(showTeamSlide ? (["team"] as const) : []), - ...(hideDesktopInstall ? [] : (["desktop-install"] as const)), - ]; +export function resolveSaasFlow(inputs: SaasFlowInputs): SlideId[] { + return resolveFlowIds(SAAS_FLOW, inputs); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts index 91af54611b..78dc40b829 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts @@ -2,7 +2,14 @@ import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide"; import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide"; import TeamSlide from "@app/components/onboarding/slides/TeamSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; -import { SlideConfig } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = "free-editor" | "usage" | "team" | "desktop-install"; @@ -12,12 +19,6 @@ export type ButtonAction = "next" | "prev" | "close" | "download-selected"; export type FlowState = Record; -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -25,27 +26,17 @@ export interface SlideFactoryParams { onDownloadUrlChange?: (url: string) => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -export interface ButtonDefinition { - key: string; - type: "button" | "icon"; - label?: string; - icon?: "chevron-left"; - variant?: "primary" | "secondary" | "default"; - group: "left" | "right"; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} +export type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; const BACK_BUTTON: ButtonDefinition = { key: "back", @@ -95,7 +86,6 @@ export const SLIDE_DEFINITIONS: Record = { DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), hero: { type: "dual-icon" }, buttons: [ - { ...BACK_BUTTON, key: "desktop-back" }, { key: "desktop-skip", type: "button", diff --git a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts index 0b76c38a8f..22f41ec12d 100644 --- a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts @@ -31,12 +31,14 @@ interface UseSaasOnboardingStateProps { * (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export function useSaasOnboardingState({ opened, onClose, hideDesktopInstall = false, + slideIds, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { const { loading } = useAuth(); const { wallet } = useWallet(); @@ -89,8 +91,9 @@ export function useSaasOnboardingState({ const flowSlideIds = useMemo( () => + slideIds ?? resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }), - [showUsageSlide, showTeamSlide, hideDesktopInstall], + [slideIds, showUsageSlide, showTeamSlide, hideDesktopInstall], ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 07e862fb8c..0641535946 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -133,7 +133,10 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { prev.capUsd !== next.capUsd || prev.noCap !== next.noCap || prev.stripeSubscriptionId !== next.stripeSubscriptionId || - prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod + prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod || + prev.docsProcessedThisPeriod !== next.docsProcessedThisPeriod || + prev.uniquePdfsThisPeriod !== next.uniquePdfsThisPeriod || + prev.sizeMultiplierPdfsThisPeriod !== next.sizeMultiplierPdfsThisPeriod ) { return next; } @@ -143,7 +146,10 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet { if ( prev.categoryBreakdown.api !== next.categoryBreakdown.api || prev.categoryBreakdown.ai !== next.categoryBreakdown.ai || - prev.categoryBreakdown.automation !== next.categoryBreakdown.automation + prev.categoryBreakdown.automation !== next.categoryBreakdown.automation || + prev.categoryDocs.api !== next.categoryDocs.api || + prev.categoryDocs.ai !== next.categoryDocs.ai || + prev.categoryDocs.automation !== next.categoryDocs.automation ) { return next; } diff --git a/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg new file mode 100644 index 0000000000..908a59c3a7 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg new file mode 100644 index 0000000000..77b6475ebf --- /dev/null +++ b/frontend/editor/src/core/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index 16cded856c..d05d05ea2b 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -198,6 +198,7 @@ const FileManager: React.FC = ({ selectedTool }) => { }} >
btn.group === "left", + // Back/icon buttons anchor the left edge; all text actions (skip + primary) + // cluster together on the right, matching the onboarding card layout. + const backButtons = slideDefinition.buttons.filter( + (btn) => btn.type === "icon", ); - const rightButtons = slideDefinition.buttons.filter( - (btn) => btn.group === "right", + const actionButtons = slideDefinition.buttons.filter( + (btn) => btn.type !== "icon", ); const resolveButtonLabel = (button: ButtonDefinition) => { @@ -75,7 +77,7 @@ export function SlideButtons({ ); } - const variant = button.variant ?? "secondary"; + const isPrimary = (button.variant ?? "secondary") === "primary"; const label = resolveButtonLabel(button); return ( @@ -83,28 +85,24 @@ export function SlideButtons({ key={button.key} onClick={() => onAction(button.action)} disabled={disabled} - variant={variant === "primary" ? "primary" : "secondary"} - accent={ - button.accent ?? (variant === "primary" ? "default" : "neutral") - } + variant={isPrimary ? "primary" : "quiet"} + accent={button.accent ?? (isPrimary ? "default" : "neutral")} > {label} ); }; - if (leftButtons.length === 0) { - return {rightButtons.map(renderButton)}; - } + const actions = {actionButtons.map(renderButton)}; - if (rightButtons.length === 0) { - return {leftButtons.map(renderButton)}; + if (backButtons.length === 0) { + return {actions}; } return ( - - {leftButtons.map(renderButton)} - {rightButtons.map(renderButton)} + + {backButtons.map(renderButton)} + {actions} ); } diff --git a/frontend/editor/src/core/components/onboarding/Onboarding.tsx b/frontend/editor/src/core/components/onboarding/Onboarding.tsx index 86976784e9..e95aaf57aa 100644 --- a/frontend/editor/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/editor/src/core/components/onboarding/Onboarding.tsx @@ -3,6 +3,7 @@ import { type StepType } from "@reactour/tour"; import { useTranslation } from "react-i18next"; import { useNavigate, useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/constants/routes"; +import { withBasePath } from "@app/constants/app"; import { dispatchTourState } from "@app/constants/events"; import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator"; import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding"; @@ -11,6 +12,7 @@ import OnboardingTour, { type CloseArgs, } from "@app/components/onboarding/OnboardingTour"; import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; import { useServerLicenseRequest, useTourRequest, @@ -24,9 +26,7 @@ import { import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; -import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; -import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; -import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; +import { getTourSteps } from "@app/components/onboarding/tourRegistry"; import { removeAllGlows } from "@app/components/onboarding/tourGlow"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useServerExperience } from "@app/hooks/useServerExperience"; @@ -77,7 +77,7 @@ export default function Onboarding() { ); const redirectToLogin = useCallback(() => { - window.location.assign("/login"); + window.location.assign(withBasePath("/login")); }, []); const handlePasswordChanged = useCallback(async () => { @@ -180,6 +180,10 @@ export default function Onboarding() { setIsTourOpen(true); break; } + case "open-processor": + actions.complete(); + navigate("/portal"); + break; case "skip-to-license": actions.complete(); break; @@ -220,77 +224,25 @@ export default function Onboarding() { const tourOrch = useTourOrchestration(); const adminTourOrch = useAdminTourOrchestration(); - const userStepsConfig = useMemo( + const tourSteps = useMemo( () => - createUserStepsConfig({ + getTourSteps(runtimeState.tourType, { t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - selectCropTool: tourOrch.selectCropTool, - loadSampleFile: tourOrch.loadSampleFile, - switchToActiveFiles: tourOrch.switchToActiveFiles, - pinFile: tourOrch.pinFile, - revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu, - modifyCropSettings: tourOrch.modifyCropSettings, - executeTool: tourOrch.executeTool, - openFilesModal, - openSettingsHelpSection: () => - adminTourOrch.navigateToSection("help"), - }, + workbench: tourOrch, + admin: adminTourOrch, + openFilesModal, + closeFilesModal, }), - [t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal], + [ + runtimeState.tourType, + t, + tourOrch, + adminTourOrch, + openFilesModal, + closeFilesModal, + ], ); - const whatsNewStepsConfig = useMemo( - () => - createWhatsNewStepsConfig({ - t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - openFilesModal, - loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, - switchToActiveFiles: tourOrch.switchToActiveFiles, - }, - }), - [t, tourOrch, closeFilesModal, openFilesModal], - ); - - const adminStepsConfig = useMemo( - () => - createAdminStepsConfig({ - t, - actions: { - saveAdminState: adminTourOrch.saveAdminState, - openConfigModal: adminTourOrch.openConfigModal, - navigateToSection: adminTourOrch.navigateToSection, - scrollNavToSection: adminTourOrch.scrollNavToSection, - }, - }), - [t, adminTourOrch], - ); - - const tourSteps = useMemo(() => { - switch (runtimeState.tourType) { - case "admin": - return Object.values(adminStepsConfig); - case "whatsnew": - return Object.values(whatsNewStepsConfig); - default: - return Object.values(userStepsConfig); - } - }, [ - adminStepsConfig, - runtimeState.tourType, - userStepsConfig, - whatsNewStepsConfig, - ]); - useEffect(() => { if (externalTourRequested) { actions.updateRuntimeState({ tourType: requestedTourType }); @@ -427,26 +379,18 @@ export default function Onboarding() { return null; } - // Show analytics modal before onboarding if needed + // Interrupt slides shown outside the normal step flow. Precedence is + // preserved by evaluation order: analytics consent → first login → MFA → + // external license notice. if (showAnalyticsModal) { - const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"]; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - analyticsError, - analyticsLoading, - }); - return ( - {}} // No skip allowed + allowDismiss={false} onAction={async (action) => { if (action === "enable-analytics") { await handleAnalyticsChoice(true); @@ -454,102 +398,72 @@ export default function Onboarding() { await handleAnalyticsChoice(false); } }} - allowDismiss={false} /> ); } if (firstLoginModalOpen) { - const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - firstLoginUsername: runtimeState.firstLoginUsername, - onPasswordChanged: handlePasswordChanged, - usingDefaultCredentials: runtimeState.usingDefaultCredentials, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handlePasswordChanged(); } }} - allowDismiss={false} /> ); } if (mfaModalOpen) { console.log("[Onboarding] Rendering MFA setup modal slide."); - const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - onMfaSetupComplete: handleMfaSetupComplete, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handleMfaSetupComplete(); } }} - allowDismiss={false} /> ); } if (showLicenseSlide) { - const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"]; - // Remove back button for external license notice - const slideDefinition = { - ...baseSlideDefinition, - buttons: baseSlideDefinition.buttons.filter( - (btn) => btn.key !== "license-back", - ), - }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - osOptions: [], - onDownloadUrlChange: () => {}, - selectedRole: null, - onRoleSelect: () => {}, - licenseNotice: effectiveLicenseNotice, - loginEnabled: serverExperience.loginEnabled, - }); - return ( - + buttons.filter((btn) => btn.key !== "license-back") + } runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice, }} - modalSlideCount={1} - currentModalSlideIndex={0} + params={{ + osOptions: [], + onDownloadUrlChange: () => {}, + licenseNotice: effectiveLicenseNotice, + loginEnabled: serverExperience.loginEnabled, + }} onSkip={closeLicenseSlide} onAction={(action) => { if (action === "see-plans") { diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx new file mode 100644 index 0000000000..f493051403 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +// The shared preview only loads the portal tokens; the onboarding modal reads +// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them +// here or the modal surface renders transparent over the dark overlay. +import "@app/styles/theme.css"; +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type SlideId, + type SlideFactoryParams, +} from "@app/components/onboarding/onboardingFlowConfig"; +import { + DEFAULT_RUNTIME_STATE, + type OnboardingRuntimeState, +} from "@app/components/onboarding/orchestrator/onboardingConfig"; + +/** + * Every onboarding modal slide, rendered through the real + * {@link OnboardingModalSlide} + {@link SLIDE_DEFINITIONS} so design changes + * here reflect the production flow. Each story is one slide (or a meaningful + * variant of one), including its hero, stepper and action buttons. + */ + +// Sensible defaults for every slide factory; individual stories override the +// few fields their slide actually reads. +const BASE_PARAMS: SlideFactoryParams = { + osLabel: "macOS (Apple Silicon)", + osUrl: "#", + osOptions: [ + { label: "macOS (Apple Silicon)", url: "#", value: "mac-arm" }, + { label: "macOS (Intel)", url: "#", value: "mac-intel" }, + { label: "Windows", url: "#", value: "windows" }, + { label: "Linux", url: "#", value: "linux" }, + ], + onDownloadUrlChange: () => {}, + selectedRole: null, + onRoleSelect: () => {}, + licenseNotice: { + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + loginEnabled: true, + firstLoginUsername: "admin", + onPasswordChanged: () => {}, + usingDefaultCredentials: false, + analyticsError: null, + analyticsLoading: false, + onMfaSetupComplete: () => {}, +}; + +interface SlideStageProps { + slideId: SlideId; + params?: Partial; + runtime?: Partial; + allowDismiss?: boolean; + /** + * Total steps in the flow. Defaults to 1 → a single standalone card with no + * progress bar or step pill (how the SaaS checklist items render). Set > 1 + * only to demonstrate the stepped-flow treatment. + */ + stepCount?: number; + /** 0-based active step, used only when stepCount > 1. */ + stepIndex?: number; +} + +function SlideStage({ + slideId, + params, + runtime, + allowDismiss = true, + stepCount = 1, + stepIndex = 0, +}: SlideStageProps) { + const merged: SlideFactoryParams = { ...BASE_PARAMS, ...params }; + // Live role selection so the SecurityCheck dropdown + its gated "Next" button + // behave the same way they do in the real flow. + const [selectedRole, setSelectedRole] = useState(merged.selectedRole); + + const definition = SLIDE_DEFINITIONS[slideId]; + const slideContent = definition.createSlide({ + ...merged, + selectedRole, + onRoleSelect: setSelectedRole, + }); + + const runtimeState: OnboardingRuntimeState = { + ...DEFAULT_RUNTIME_STATE, + ...runtime, + selectedRole, + licenseNotice: merged.licenseNotice ?? DEFAULT_RUNTIME_STATE.licenseNotice, + }; + + return ( + {}} + onAction={() => {}} + allowDismiss={allowDismiss} + /> + ); +} + +const meta: Meta = { + title: "Onboarding/Modal Slides", + component: SlideStage, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** "Welcome to Stirling" — the V2 intro slide (rocket hero). */ +export const Welcome: Story = { args: { slideId: "welcome" } }; + +/** The only stepped example: a multi-step flow shows the step pill + progress + * bar. Every other story is a standalone single card (no steps). */ +export const SteppedFlowExample: Story = { + args: { slideId: "admin-overview", stepCount: 9, stepIndex: 4 }, +}; + +/** Force a password change on first login (user types current + new). */ +export const FirstLogin: Story = { args: { slideId: "first-login" } }; + +/** First login when the account is still on the default `stirling` password — + * the current-password field is hidden. */ +export const FirstLoginDefaultCredentials: Story = { + args: { + slideId: "first-login", + params: { usingDefaultCredentials: true }, + }, +}; + +/** Desktop app download prompt with an OS picker (dual-icon hero). */ +export const DesktopInstall: Story = { args: { slideId: "desktop-install" } }; + +/** Role confirmation — the "Next" button stays disabled until a role is picked. */ +export const SecurityCheck: Story = { args: { slideId: "security-check" } }; + +/** Admin overview with login mode already enabled (diamond hero). */ +export const AdminOverviewLoginEnabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: true }, + }, +}; + +/** Admin overview before login mode is enabled — different body copy. */ +export const AdminOverviewLoginDisabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: false }, + }, +}; + +/** Server license, within the free tier. */ +export const ServerLicense: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, + }, +}; + +/** Server license, over the free-tier seat limit — "Upgrade now" CTA. */ +export const ServerLicenseOverLimit: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, + }, +}; + +/** Quick tour offer before dropping the user into the tools. */ +export const TourOverview: Story = { args: { slideId: "tour-overview" } }; + +/** Opt-in analytics choice (analytics hero). */ +export const AnalyticsChoice: Story = { args: { slideId: "analytics-choice" } }; + +/** Analytics choice showing an error banner (e.g. save failed). */ +export const AnalyticsChoiceError: Story = { + args: { + slideId: "analytics-choice", + params: { analyticsError: "Couldn't save your analytics preference." }, + }, +}; + +/** Two-factor setup (fetches a QR on mount; shows its error state without a + * backend in Storybook). */ +export const MfaSetup: Story = { args: { slideId: "mfa-setup" } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx index b8135739bd..7c54713665 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -1,29 +1,26 @@ /** - * OnboardingModalSlide Component + * OnboardingModalSlide * - * Renders a single modal slide in the onboarding flow. - * Handles the hero image, content, stepper, and button actions. + * Editor-flow adapter over the shared {@link OnboardingSlideShell}: maps a + * SLIDE_DEFINITIONS entry (hero + buttons) and its resolved content onto the + * shell so the editor onboarding renders the same card as every other flow. */ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ActionIcon } from "@app/ui/ActionIcon"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import type { SlideDefinition, ButtonAction, + ButtonDefinition, } from "@app/components/onboarding/onboardingFlowConfig"; import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; import type { SlideConfig } from "@app/types/types"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface OnboardingModalSlideProps { slideDefinition: SlideDefinition; @@ -36,6 +33,13 @@ interface OnboardingModalSlideProps { allowDismiss?: boolean; } +const HERO_ICON: Record = { + rocket: "rocket-launch", + shield: "verified-user-outline", + lock: "lock-outline", + analytics: "analytics", +}; + export default function OnboardingModalSlide({ slideDefinition, slideContent, @@ -47,165 +51,60 @@ export default function OnboardingModalSlide({ allowDismiss = true, }: OnboardingModalSlideProps) { const { t } = useTranslation(); - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -
-
- Stirling icon -
-
- ); - } + const { licenseNotice } = runtimeState; + const flowState = { selectedRole: runtimeState.selectedRole }; - return ( -
- {slideDefinition.hero.type === "rocket" && ( - - )} - {slideDefinition.hero.type === "shield" && ( - - )} - {slideDefinition.hero.type === "lock" && ( - - )} - {slideDefinition.hero.type === "analytics" && ( - - )} - {slideDefinition.hero.type === "diamond" && ( - - )} - {slideDefinition.hero.type === "logo" && ( - Stirling logo - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "diamond" ? ( + + ) : HERO_ICON[heroType] ? ( + + ) : null} + ); + + const resolveLabel = (button: ButtonDefinition) => { + if ( + button.type === "button" && + slideDefinition.id === "server-license" && + button.action === "see-plans" && + licenseNotice.isOverLimit + ) { + return t("onboarding.serverLicense.upgrade", "Upgrade now →"); + } + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + accent: button.accent, + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - onAction(action as ButtonAction)} onClose={onSkip} - closeOnClickOutside={false} - closeOnEscape={allowDismiss} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - }, - }} - > - -
- - {allowDismiss && ( - - - - )} -
- {renderHero()} -
-
- -
- -
- {slideContent.title} -
- -
-
- {slideContent.body} -
- -
- - {modalSlideCount > 1 && ( - - )} - -
- -
-
-
-
-
+ allowDismiss={allowDismiss} + /> ); } diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx new file mode 100644 index 0000000000..3a8a2a57e4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -0,0 +1,226 @@ +import type { ReactNode } from "react"; +import { Modal } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button, type ButtonAccent } from "@app/ui/Button"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import stirlingMark from "@app/assets/brand/modern-logo/logo512.png"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; + +/** A footer button. `action` is an opaque string handled by the caller. */ +export interface ShellButton { + key: string; + /** Chevron-left icon button (a back control) instead of a labelled button. */ + back?: boolean; + label?: string; + /** Filled primary (blue) vs. quiet text button. */ + primary?: boolean; + /** Accent override for a primary button (e.g. "premium"). */ + accent?: ButtonAccent; + action: string; + disabled?: boolean; +} + +export interface OnboardingSlideShellProps { + opened?: boolean; + /** Hero art node — use {@link ShellHero} to render the app mark or a glyph. */ + hero: ReactNode; + slideKey: string; + title: ReactNode; + body: ReactNode; + stepIndex: number; + stepCount: number; + buttons: ShellButton[]; + onAction: (action: string) => void; + onClose: () => void; + allowDismiss?: boolean; +} + +/** + * Hero art for the inset panel. `appIcon` renders the Stirling app mark + * directly; otherwise the children glyph sits inside a soft white tile. + */ +export function ShellHero({ + appIcon = false, + children, +}: { + appIcon?: boolean; + children?: ReactNode; +}) { + if (appIcon) { + return ( + Stirling + ); + } + return
{children}
; +} + +/** + * Shared onboarding slide chrome: branded header + step progress, an inset + * hero panel, left-aligned title/body, and a right-aligned action footer. + * Generic over button actions so every flow (editor, SaaS, portal) renders + * the same card. + */ +export default function OnboardingSlideShell({ + opened = true, + hero, + slideKey, + title, + body, + stepIndex, + stepCount, + buttons, + onAction, + onClose, + allowDismiss = true, +}: OnboardingSlideShellProps) { + const { t } = useTranslation(); + const showProgress = stepCount > 1; + + // Back/icon buttons anchor the left; text actions cluster on the right. + // A back control can't do anything on the first slide, so hide it there. + const backButtons = stepIndex === 0 ? [] : buttons.filter((b) => b.back); + const actionButtons = buttons.filter((b) => !b.back); + + const renderButton = (button: ShellButton) => ( + + ); + + const actions = ( +
{actionButtons.map(renderButton)}
+ ); + + return ( + +
+
+
+ + Stirling +
+
+ {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
+
+ + {showProgress && ( +
+ {Array.from({ length: stepCount }, (_, index) => ( + + ))} +
+ )} + +
+ +
+
+
+ {hero} +
+
+ +
+ {title} +
+ +
+ {body} + +
+ +
+ {backButtons.length === 0 ? ( +
{actions}
+ ) : ( +
+
+ {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
+ {actions} +
+ )} +
+
+
+ + ); +} diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx index c38d9f952b..f9c66385d6 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx @@ -6,7 +6,7 @@ * when the tour is open but onboarding is inactive. */ -import React from "react"; +import React, { type ReactNode } from "react"; import { TourProvider, useTour, type StepType } from "@reactour/tour"; import { ActionIcon } from "@app/ui/ActionIcon"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; @@ -15,6 +15,19 @@ import CheckIcon from "@mui/icons-material/Check"; import type { TFunction } from "i18next"; import i18n from "@app/i18n"; +/** + * Renders tour copy that uses for emphasis as real React elements, + * so there is no raw-HTML injection surface (avoids dangerouslySetInnerHTML). + * Any other markup is shown as plain text. + */ +export function renderTourContent(content: string): ReactNode { + return content.split(/(.*?<\/strong>)/g).map((part, index) => { + const match = part.match(/^(.*?)<\/strong>$/); + if (match) return {match[1]}; + return {part}; + }); +} + /** * TourContent - Controls the tour visibility * Syncs the forceOpen prop with the reactour tour state. @@ -49,12 +62,14 @@ interface CloseArgs { interface OnboardingTourProps { tourSteps: StepType[]; - tourType: "admin" | "tools" | "whatsnew"; + // Open tour id (see tourRegistry.ts); "admin" gets the dark mask. + tourType: string; isRTL: boolean; t: TFunction; isOpen: boolean; onAdvance: (args: AdvanceArgs) => void; onClose: (args: CloseArgs) => void; + dimBackground?: boolean; } export default function OnboardingTour({ @@ -65,6 +80,7 @@ export default function OnboardingTour({ isOpen, onAdvance, onClose, + dimBackground = true, }: OnboardingTourProps) { if (!isOpen) return null; @@ -99,6 +115,11 @@ export default function OnboardingTour({ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", maxWidth: "400px", }), + maskWrapper: (base) => ({ + ...base, + // 0 = no dim: the page stays fully visible behind the popover. + opacity: dimBackground ? 0.7 : 0, + }), maskArea: (base) => ({ ...base, rx: 8, @@ -162,10 +183,9 @@ export default function OnboardingTour({ ), Content: ({ content }: { content: string }) => ( -
+
+ {renderTourContent(content)} +
), }} > diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx new file mode 100644 index 0000000000..2ba340f47e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx @@ -0,0 +1,67 @@ +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type ButtonDefinition, + type SlideFactoryParams, + type SlideId, +} from "@app/components/onboarding/onboardingFlowConfig"; +import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; + +interface StaticOnboardingSlideProps { + slideId: SlideId; + runtimeState: OnboardingRuntimeState; + onSkip: () => void; + onAction: (action: ButtonAction) => void; + allowDismiss?: boolean; + /** Slide-specific createSlide params layered over the empty defaults. */ + params?: Partial; + /** Optional transform of the definition's buttons (e.g. drop a back button). */ + transformButtons?: (buttons: ButtonDefinition[]) => ButtonDefinition[]; +} + +/** + * Renders a single onboarding slide outside the normal step flow — the + * "interrupt" modals (analytics consent, first-login, MFA, external license + * notice). Each of these was previously an inline early-return in Onboarding + * that duplicated the same createSlide + OnboardingModalSlide boilerplate. + * + * Rendered as its own component (keyed by slideId at the call site) so the + * slide's internal hooks live in an isolated, stable scope rather than the + * parent's, which avoids hook-order churn when the active interrupt changes. + */ +export default function StaticOnboardingSlide({ + slideId, + runtimeState, + onSkip, + onAction, + allowDismiss, + params, + transformButtons, +}: StaticOnboardingSlideProps) { + const base = SLIDE_DEFINITIONS[slideId]; + const definition = transformButtons + ? { ...base, buttons: transformButtons(base.buttons) } + : base; + + const slideContent = definition.createSlide({ + osLabel: "", + osUrl: "", + selectedRole: null, + onRoleSelect: () => {}, + ...params, + }); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts index 1d8083201c..cff95cb63c 100644 --- a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts @@ -33,6 +33,165 @@ interface CreateAdminStepsConfigArgs { actions: AdminStepActions; } +// Delay before applying glow so the target section has mounted after navigation. +const GLOW_DELAY_MS = 100; + +/** + * Declarative spec for an admin tour step. Most steps do the same thing — + * clear existing glows, navigate to a settings section, then glow a set of nav + * items — so that behaviour is expressed as data (`section` + `glow`) and the + * runner below turns it into the reactour enter/after hooks. This replaces the + * removeGlow→navigate→setTimeout→addGlow block that was previously copy-pasted + * into every step. + */ +interface AdminStepSpec { + step: AdminTourStep; + selector: string; + contentKey: string; + contentDefault: string; + position: StepType["position"]; + padding?: number; + highlightedSelectors?: string[]; + /** Navigate to this settings section on enter (clears glows first). */ + section?: string; + /** Selectors to glow shortly after navigating to `section`. */ + glow?: string[]; + /** Clear glows on enter without navigating (settings-overview step). */ + clearGlowOnEnter?: boolean; + /** Save admin/workbench state on enter (first step). */ + saveStateOnEnter?: boolean; + /** Open the config modal after this step (config-button step). */ + openConfigAfter?: boolean; + /** Scroll the settings nav to this section after this step. */ + scrollToAfter?: string; + /** Wait for the step's own selector to be present + highlightable on enter. */ + waitForSelectorOnEnter?: boolean; +} + +const NAV = { + people: [ + '[data-tour="admin-people-nav"]', + '[data-tour="admin-teams-nav"]', + '[data-tour="settings-content-area"]', + ], + adminGeneral: [ + '[data-tour="admin-adminGeneral-nav"]', + '[data-tour="admin-adminFeatures-nav"]', + '[data-tour="admin-adminEndpoints-nav"]', + '[data-tour="settings-content-area"]', + ], + adminDatabase: [ + '[data-tour="admin-adminDatabase-nav"]', + '[data-tour="settings-content-area"]', + ], + adminConnections: [ + '[data-tour="admin-adminConnections-nav"]', + '[data-tour="settings-content-area"]', + ], + adminTools: [ + '[data-tour="admin-adminAudit-nav"]', + '[data-tour="admin-adminUsage-nav"]', + '[data-tour="settings-content-area"]', + ], +} as const; + +const ADMIN_STEP_SPECS: AdminStepSpec[] = [ + { + step: AdminTourStep.WELCOME, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.welcome", + contentDefault: + "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + position: "right", + saveStateOnEnter: true, + }, + { + step: AdminTourStep.CONFIG_BUTTON, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.configButton", + contentDefault: + "Open Settings to access all system configuration and administrative controls.", + position: "right", + openConfigAfter: true, + }, + { + step: AdminTourStep.SETTINGS_OVERVIEW, + selector: ".modal-nav", + contentKey: "adminOnboarding.settingsOverview", + contentDefault: + "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + position: "right", + padding: 0, + clearGlowOnEnter: true, + }, + { + step: AdminTourStep.TEAMS_AND_USERS, + selector: '[data-tour="admin-people-nav"]', + contentKey: "adminOnboarding.teamsAndUsers", + contentDefault: + "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + position: "right", + section: "people", + glow: [...NAV.people], + highlightedSelectors: [...NAV.people], + }, + { + step: AdminTourStep.SYSTEM_CUSTOMIZATION, + selector: '[data-tour="admin-adminGeneral-nav"]', + contentKey: "adminOnboarding.systemCustomization", + contentDefault: + "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", + position: "right", + section: "adminGeneral", + glow: [...NAV.adminGeneral], + highlightedSelectors: [...NAV.adminGeneral], + }, + { + step: AdminTourStep.DATABASE_SECTION, + selector: '[data-tour="admin-adminDatabase-nav"]', + contentKey: "adminOnboarding.databaseSection", + contentDefault: + "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + position: "right", + section: "adminDatabase", + glow: [...NAV.adminDatabase], + highlightedSelectors: [...NAV.adminDatabase], + }, + { + step: AdminTourStep.CONNECTIONS_SECTION, + selector: '[data-tour="admin-adminConnections-nav"]', + contentKey: "adminOnboarding.connectionsSection", + contentDefault: + "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", + position: "right", + section: "adminConnections", + glow: [...NAV.adminConnections], + highlightedSelectors: [...NAV.adminConnections], + scrollToAfter: "adminAudit", + }, + { + step: AdminTourStep.ADMIN_TOOLS, + selector: '[data-tour="admin-adminAudit-nav"]', + contentKey: "adminOnboarding.adminTools", + contentDefault: + "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + position: "right", + section: "adminAudit", + glow: [...NAV.adminTools], + highlightedSelectors: [...NAV.adminTools], + }, + { + step: AdminTourStep.WRAP_UP, + selector: '[data-tour="admin-help-nav"]', + contentKey: "adminOnboarding.wrapUp", + contentDefault: + "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", + position: "right", + section: "help", + waitForSelectorOnEnter: true, + }, +]; + export function createAdminStepsConfig({ t, actions, @@ -44,183 +203,57 @@ export function createAdminStepsConfig({ scrollNavToSection, } = actions; - return { - [AdminTourStep.WELCOME]: { - selector: '[data-tour="config-button"]', - content: t( - "adminOnboarding.welcome", - "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", - ), - position: "right", - padding: 10, - action: () => { - saveAdminState(); - }, - }, - [AdminTourStep.CONFIG_BUTTON]: { - selector: '[data-tour="config-button"]', - content: t( - "adminOnboarding.configButton", - "Open Settings to access all system configuration and administrative controls.", - ), - position: "right", - padding: 10, - actionAfter: () => { - openConfigModal(); - }, - }, - [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: ".modal-nav", - content: t( - "adminOnboarding.settingsOverview", - "This is the Settings Panel. Admin settings are organised by category for easy navigation.", - ), - position: "right", - padding: 0, - action: () => { - removeAllGlows(); - }, - }, - [AdminTourStep.TEAMS_AND_USERS]: { - selector: '[data-tour="admin-people-nav"]', - highlightedSelectors: [ - '[data-tour="admin-people-nav"]', - '[data-tour="admin-teams-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.teamsAndUsers", - "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("people"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-people-nav"]', - '[data-tour="admin-teams-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.SYSTEM_CUSTOMIZATION]: { - selector: '[data-tour="admin-adminGeneral-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminGeneral-nav"]', - '[data-tour="admin-adminFeatures-nav"]', - '[data-tour="admin-adminEndpoints-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.systemCustomization", - "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminGeneral"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminGeneral-nav"]', - '[data-tour="admin-adminFeatures-nav"]', - '[data-tour="admin-adminEndpoints-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.DATABASE_SECTION]: { - selector: '[data-tour="admin-adminDatabase-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminDatabase-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.databaseSection", - "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminDatabase"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminDatabase-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.CONNECTIONS_SECTION]: { - selector: '[data-tour="admin-adminConnections-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminConnections-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.connectionsSection", - "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminConnections"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminConnections-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - actionAfter: async () => { - await scrollNavToSection("adminAudit"); - }, - }, - [AdminTourStep.ADMIN_TOOLS]: { - selector: '[data-tour="admin-adminAudit-nav"]', - highlightedSelectors: [ - '[data-tour="admin-adminAudit-nav"]', - '[data-tour="admin-adminUsage-nav"]', - '[data-tour="settings-content-area"]', - ], - content: t( - "adminOnboarding.adminTools", - "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", - ), - position: "right", - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection("adminAudit"); - setTimeout(() => { - addGlowToElements([ - '[data-tour="admin-adminAudit-nav"]', - '[data-tour="admin-adminUsage-nav"]', - '[data-tour="settings-content-area"]', - ]); - }, 100); - }, - }, - [AdminTourStep.WRAP_UP]: { - selector: '[data-tour="admin-help-nav"]', - content: t( - "adminOnboarding.wrapUp", - "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", - ), - position: "right", - padding: 10, - action: async () => { - removeAllGlows(); - navigateToSection("help"); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); - }, - }, + const build = (spec: AdminStepSpec): StepType => { + const step: StepType = { + selector: spec.selector, + content: t(spec.contentKey, spec.contentDefault), + position: spec.position, + padding: spec.padding ?? 10, + }; + if (spec.highlightedSelectors) { + step.highlightedSelectors = spec.highlightedSelectors; + } + + const hasEnter = + spec.saveStateOnEnter || + spec.clearGlowOnEnter || + !!spec.section || + spec.waitForSelectorOnEnter; + if (hasEnter) { + step.action = async () => { + if (spec.saveStateOnEnter) saveAdminState(); + if (spec.clearGlowOnEnter) removeAllGlows(); + if (spec.section) { + removeAllGlows(); + navigateToSection(spec.section); + if (spec.glow) { + const glow = spec.glow; + setTimeout(() => addGlowToElements(glow), GLOW_DELAY_MS); + } + } + if (spec.waitForSelectorOnEnter) { + await waitForElement(spec.selector, 5000); + await waitForHighlightable(spec.selector, 5000); + } + }; + } + + const hasAfter = spec.openConfigAfter || !!spec.scrollToAfter; + if (hasAfter) { + step.actionAfter = async () => { + if (spec.openConfigAfter) openConfigModal(); + if (spec.scrollToAfter) await scrollNavToSection(spec.scrollToAfter); + }; + } + + return step; }; + + return ADMIN_STEP_SPECS.reduce( + (acc, spec) => { + acc[spec.step] = build(spec); + return acc; + }, + {} as Record, + ); } diff --git a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts index 6eb843f4a5..167caac6a4 100644 --- a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts @@ -1,4 +1,5 @@ import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; +import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; @@ -7,12 +8,20 @@ import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide"; import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide"; -import { SlideConfig, LicenseNotice } from "@app/types/types"; -import type { ButtonAccent } from "@app/ui/Button"; +import { LicenseNotice } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -40,6 +49,7 @@ export type ButtonAction = | "launch-admin" | "launch-tools" | "launch-auto" + | "open-processor" | "see-plans" | "skip-to-license" | "skip-tour" @@ -50,12 +60,6 @@ export interface FlowState { selectedRole: "admin" | "user" | null; } -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -74,29 +78,17 @@ export interface SlideFactoryParams { onMfaSetupComplete?: () => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -export interface ButtonDefinition { - key: string; - type: "button" | "icon"; - label?: string; - icon?: "chevron-left"; - variant?: "primary" | "secondary" | "default"; - /** Accent for the shared Button; defaults to neutral. */ - accent?: ButtonAccent; - group: "left" | "right"; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} +export type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; export const SLIDE_DEFINITIONS: Record = { "first-login": { @@ -129,6 +121,36 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + "processor-intro": { + id: "processor-intro", + createSlide: () => ProcessorIntroSlide(), + hero: { type: "diamond" }, + buttons: [ + { + key: "processor-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", + }, + { + key: "processor-skip", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "next", + }, + { + key: "processor-open", + type: "button", + label: "onboarding.processorIntro.cta", + variant: "primary", + group: "right", + action: "open-processor", + }, + ], + }, "desktop-install": { id: "desktop-install", createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => diff --git a/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts new file mode 100644 index 0000000000..23fe6cc6c9 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts @@ -0,0 +1,65 @@ +import type { SlideConfig } from "@app/types/types"; +import type { ButtonAccent } from "@app/ui/Button"; + +/** + * Shared shape for onboarding slide flows. Both the core/editor flow and the + * SaaS flow describe the same thing — a sequence of slides, each with a hero, + * body content, and a row of buttons — so the structure lives here once and is + * parameterised per flow by its own action/hero/state unions. Previously these + * interfaces were copy-pasted into each flow config. + */ + +export interface OSOption { + label: string; + url: string; + value: string; +} + +export interface ButtonDefinition { + key: string; + type: "button" | "icon"; + label?: string; + icon?: "chevron-left"; + variant?: "primary" | "secondary" | "default"; + /** Accent for the shared Button; defaults to neutral. */ + accent?: ButtonAccent; + group: "left" | "right"; + action: Action; + disabledWhen?: (state: State) => boolean; +} + +export interface HeroDefinition { + type: Hero; +} + +export interface SlideDefinition< + Id extends string, + Action extends string, + State, + Hero extends string, + Params, +> { + id: Id; + createSlide: (params: Params) => SlideConfig; + hero: HeroDefinition; + buttons: ButtonDefinition[]; +} + +/** A single entry in a conditionally-resolved flow. */ +export interface FlowStep { + id: Id; + /** Included in the resolved flow only when this returns true. */ + when: (ctx: Ctx) => boolean; +} + +/** + * Resolves an ordered flow to the ids whose `when` predicate holds for `ctx`. + * This is the one operation both flows share: "the flow is the steps that apply + * right now, in order". + */ +export function resolveFlowIds( + steps: FlowStep[], + ctx: Ctx, +): Id[] { + return steps.filter((step) => step.when(ctx)).map((step) => step.id); +} diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 6e10ec97f3..9b8ba965c8 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -1,6 +1,7 @@ export type OnboardingStepId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -15,7 +16,8 @@ export type OnboardingStepType = "modal-slide" | "tool-prompt"; export interface OnboardingRuntimeState { selectedRole: "admin" | "user" | null; tourRequested: boolean; - tourType: "admin" | "tools" | "whatsnew"; + // Open key into the tour registry (see tourRegistry.ts). + tourType: string; isDesktopApp: boolean; desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; @@ -44,6 +46,7 @@ export interface OnboardingStep { slideId?: | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -88,6 +91,13 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ // Desktop has its own onboarding modal (DesktopOnboardingModal) condition: (ctx) => !ctx.isDesktopApp, }, + { + id: "processor-intro", + type: "modal-slide", + slideId: "processor-intro", + // Admins can manage policies in the portal/processor; regular users can't. + condition: (ctx) => ctx.effectiveIsAdmin, + }, { id: "admin-overview", type: "modal-slide", @@ -100,17 +110,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: "desktop-install", condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, - { - id: "security-check", - type: "modal-slide", - slideId: "security-check", - condition: () => false, - }, - { - id: "tool-layout", - type: "tool-prompt", - condition: () => false, - }, { id: "tour-overview", type: "modal-slide", diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 73f45cb1ea..084761849d 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -2,6 +2,85 @@ const STORAGE_PREFIX = "onboarding"; const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; +// Per-flow persistence lives under a single namespace so completion state +// composes across build flavors and, in future, checklist-style flows that +// track individual step completion (see setStepDone / getFlowProgress). +const flowSeenKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::seen`; +const flowProgressKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::progress`; + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`[onboardingStorage] Error writing ${key}:`, error); + } +} + +/** Whether a named flow (e.g. "saas", "portal") has been seen/dismissed. */ +export function hasSeenFlow(flowId: string): boolean { + if (typeof window === "undefined") return false; + try { + return localStorage.getItem(flowSeenKey(flowId)) === "true"; + } catch { + return false; + } +} + +/** Marks a named flow as seen so it does not reappear. */ +export function markFlowSeen(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(flowSeenKey(flowId), "true"); + } catch (error) { + console.error(`[onboardingStorage] Error marking flow "${flowId}":`, error); + } +} + +/** Completed step ids for a checklist-style flow, in completion order. */ +export function getFlowProgress(flowId: string): string[] { + const value = readJson(flowProgressKey(flowId), []); + return Array.isArray(value) ? value : []; +} + +/** Whether a single step within a flow has been completed. */ +export function isStepDone(flowId: string, stepId: string): boolean { + return getFlowProgress(flowId).includes(stepId); +} + +/** Records a single step within a flow as done (idempotent). */ +export function setStepDone(flowId: string, stepId: string): void { + const progress = getFlowProgress(flowId); + if (progress.includes(stepId)) return; + writeJson(flowProgressKey(flowId), [...progress, stepId]); +} + +/** Clears both the seen flag and step progress for a flow. */ +export function resetFlow(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(flowSeenKey(flowId)); + localStorage.removeItem(flowProgressKey(flowId)); + } catch (error) { + console.error( + `[onboardingStorage] Error resetting flow "${flowId}":`, + error, + ); + } +} + export function isOnboardingCompleted(): boolean { if (typeof window === "undefined") return false; try { diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 44052636d1..d0ec8a66b5 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -41,13 +41,10 @@ function getInitialRuntimeState( try { const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; - const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); + // Any stored tour id is accepted (validated against the registry at render); + // fall back to the default tour type when absent. const tourType = - sessionTourType === "admin" || - sessionTourType === "tools" || - sessionTourType === "whatsnew" - ? sessionTourType - : "whatsnew"; + sessionStorage.getItem(SESSION_TOUR_TYPE) ?? baseState.tourType; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as | "admin" | "user" @@ -183,6 +180,7 @@ export function useOnboardingOrchestrator( ); const [isPaused, setIsPaused] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + const [manuallyStarted, setManuallyStarted] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(-1); const migrationDone = useRef(false); const initialIndexSet = useRef(false); @@ -289,23 +287,8 @@ export function useOnboardingOrchestrator( useEffect(() => { if (configLoading || !adminStatusResolved) return; - // If there are no steps to show, mark initialized/completed baseline - if (activeFlow.length === 0) { - setCurrentStepIndex(0); - initialIndexSet.current = true; - return; - } - - // If onboarding has been completed, don't show it - if (isOnboardingCompleted()) { - setCurrentStepIndex(activeFlow.length); - initialIndexSet.current = true; - return; - } - - // Start from the beginning if (!initialIndexSet.current) { - setCurrentStepIndex(0); + setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; } }, [activeFlow, configLoading, adminStatusResolved]); @@ -326,6 +309,7 @@ export function useOnboardingOrchestrator( !isPaused && !isComplete && isInitialized && + manuallyStarted && currentStep !== null; const isLoading = configLoading || @@ -389,6 +373,7 @@ export function useOnboardingOrchestrator( if (index !== -1) { setCurrentStepIndex(index); setIsPaused(false); + setManuallyStarted(true); } }, [activeFlow], diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index 97329db5d7..efb95b95bb 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -17,6 +17,41 @@ interface DesktopInstallTitleProps { onDownloadUrlChange?: (url: string) => void; } +/** Brand marks (simple-icons paths) so each option shows its real OS logo. */ +const OS_ICON_PATHS: Record = { + apple: + "M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701", + windows: + "M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801", + linux: + "M14.62 8.35c-.18.11-.4.28-.66.35-.24.08-.5.16-.72.16-.4 0-.72-.13-.98-.28-.16-.09-.29-.19-.4-.28l-.08-.06a.29.29 0 0 0-.34.02.28.28 0 0 0-.05.4c.02.02.16.16.4.3.24.16.6.32 1.05.32.34 0 .66-.08.94-.18.28-.1.5-.22.68-.34.42-.28.68-.6.68-.6a.28.28 0 0 0-.06-.4.29.29 0 0 0-.4.06s-.2.26-.53.45zM9.36 6.6c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zm5.3 0c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zM12 0C6.9 0 4.28 3.98 4.34 7.66c.06 3.68-.9 4.98-1.72 6.28C1.8 15.24.9 16.4.9 18.1c0 .96.4 1.6 1 2 .6.4 1.36.5 2.1.6.74.1 1.46.2 2 .5.54.3.9.8 1.7.9.4.06.86 0 1.3-.2.44-.2.86-.54 1.16-1.06h1.68c.3.52.72.86 1.16 1.06.44.2.9.26 1.3.2.8-.1 1.16-.6 1.7-.9.54-.3 1.26-.4 2-.5.74-.1 1.5-.2 2.1-.6.6-.4 1-1.04 1-2 0-1.7-.9-2.86-1.72-4.16-.82-1.3-1.78-2.6-1.72-6.28C19.72 3.98 17.1 0 12 0z", +}; + +/** Map an OS option to its brand-icon key from its value/label. */ +function osIconKey(option: OSOption): string | null { + const text = `${option.value} ${option.label}`.toLowerCase(); + if (/(mac|apple|osx|darwin)/.test(text)) return "apple"; + if (/win/.test(text)) return "windows"; + if (/linux/.test(text)) return "linux"; + return null; +} + +function OsIcon({ os }: { os: string }) { + const d = OS_ICON_PATHS[os]; + if (!d) return null; + return ( + + ); +} + export const DesktopInstallTitle: React.FC = ({ osLabel, osUrl, @@ -51,7 +86,7 @@ export const DesktopInstallTitle: React.FC = ({ // If only one option or no options, don't show dropdown if (osOptions.length <= 1) { - return
{title}
; + return
{title}
; } return ( @@ -59,13 +94,12 @@ export const DesktopInstallTitle: React.FC = ({ style={{ display: "flex", alignItems: "center", - justifyContent: "center", gap: "0.5rem", width: "100%", }} > {title} - + = ({ {osOptions.map((option) => { const isSelected = option.url === selectedOsUrl; + const iconKey = osIconKey(option); return ( handleOsSelect(option)} + leftSection={iconKey ? : undefined} style={{ backgroundColor: isSelected - ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + ? "var(--bg-muted, #f1f5f9)" : "transparent", - color: isSelected - ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" - : "inherit", + color: "var(--onboarding-title, #0f172a)", + fontWeight: isSelected ? 600 : 500, }} > {option.label} diff --git a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx index c018d69265..41c8308f2b 100644 --- a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -23,9 +23,10 @@ import { accountService } from "@app/services/accountService"; import { useAccountLogout } from "@app/extensions/accountLogout"; import { useAuth } from "@app/auth/UseSession"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { BASE_PATH } from "@app/constants/app"; +import { BASE_PATH, withBasePath } from "@app/constants/app"; import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse"; +import i18n from "@app/i18n"; interface MFASetupSlideProps { onMfaSetupComplete?: () => void; @@ -62,12 +63,15 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( axiosError.response?.data?.error || - "Unable to start two-factor setup. Please try again.", + t( + "onboarding.mfa.setupError", + "Unable to start two-factor setup. Please try again.", + ), ); } finally { setMfaLoading(false); } - }, []); + }, [t]); useEffect(() => { setupCompleteRef.current = setupComplete; @@ -84,7 +88,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { }, [fetchMfaSetup]); const redirectToLogin = useCallback(() => { - window.location.assign("/login"); + window.location.assign(withBasePath("/login")); }, []); const onLogout = useCallback(async () => { @@ -96,7 +100,12 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { event.preventDefault(); if (!mfaSetupCode.trim()) { - setMfaError("Enter the authentication code to continue."); + setMfaError( + t( + "onboarding.mfa.enterCodeError", + "Enter the authentication code to continue.", + ), + ); return; } @@ -110,13 +119,16 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( axiosError.response?.data?.error || - "Unable to enable two-factor authentication. Check the code and try again.", + t( + "onboarding.mfa.enableError", + "Unable to enable two-factor authentication. Check the code and try again.", + ), ); } finally { setSubmitting(false); } }, - [mfaSetupCode, onMfaSetupComplete], + [mfaSetupCode, onMfaSetupComplete, t], ); const isReady = Boolean(mfaSetupData); @@ -138,15 +150,30 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { - Step-by-step + {t("onboarding.mfa.stepByStep", "Step-by-step")}
    -
  1. Open Google Authenticator, Authy, or 1Password.
  2. -
  3. Scan the QR code or enter the setup key below.
  4. -
  5. Enter the 6-digit code from your app.
  6. +
  7. + {t( + "onboarding.mfa.openAuthenticator", + "Open Google Authenticator, Authy, or 1Password.", + )} +
  8. +
  9. + {t( + "onboarding.mfa.scanQrCode", + "Scan the QR code or enter the setup key below.", + )} +
  10. +
  11. + {t( + "onboarding.mfa.enterCode", + "Enter the 6-digit code from your app.", + )} +
- Setup key (manual entry) + {t("onboarding.mfa.setupKey", "Setup key (manual entry)")} - Secure your account by linking an authenticator app. Scan the QR - code or enter the setup key, then confirm the 6-digit code to - finish. + {t( + "onboarding.mfa.description", + "Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the 6-digit code to finish.", + )} {mfaError && ( @@ -215,7 +243,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { onClick={fetchMfaSetup} disabled={mfaLoading || submitting || setupComplete} > - Regenerate QR code + {t("onboarding.mfa.regenerateQrCode", "Regenerate QR code")} {setupComplete && ( - MFA has been enabled. You can now continue. + {t( + "onboarding.mfa.success", + "MFA has been enabled. You can now continue.", + )} )} @@ -249,7 +280,7 @@ export default function MFASetupSlide({ }: MFASetupSlideProps = {}): SlideConfig { return { key: "mfa-setup-slide", - title: "Multi-Factor Authentication Setup", + title: i18n.t("onboarding.mfa.title", "Multi-Factor Authentication Setup"), body: , background: { gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx new file mode 100644 index 0000000000..ed910ca48a --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx @@ -0,0 +1,30 @@ +import { Trans } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; + +const ProcessorIntroBody = () => ( + + }} + defaults="Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." + /> + +); + +export default function ProcessorIntroSlide(): SlideConfig { + return { + key: "processor-intro", + title: ( + + ), + body: , + background: { + gradientStops: ["#2563EB", "#7C3AED"], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} diff --git a/frontend/editor/src/core/components/onboarding/tourRegistry.ts b/frontend/editor/src/core/components/onboarding/tourRegistry.ts new file mode 100644 index 0000000000..8213e5006e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/tourRegistry.ts @@ -0,0 +1,105 @@ +import type { StepType } from "@reactour/tour"; +import type { TFunction } from "i18next"; +import type { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import type { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; +import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; +import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; + +type WorkbenchTourActions = ReturnType; +type AdminTourActions = ReturnType; + +/** + * Everything a tour's step builder might need, resolved once by the onboarding + * component from the two orchestration contexts. Each tour picks what it uses. + */ +export interface TourBuildContext { + t: TFunction; + workbench: WorkbenchTourActions; + admin: AdminTourActions; + openFilesModal: () => void; + closeFilesModal: () => void; +} + +export interface TourDefinition { + id: string; + build: (ctx: TourBuildContext) => StepType[]; +} + +/** + * Registry of guided tours. Adding a tour is a single entry here — no changes + * to the onboarding component's rendering. Tour ids are open strings so future + * builds/features can register their own without editing a central union. + */ +export const TOUR_REGISTRY: Record = { + admin: { + id: "admin", + build: ({ t, admin }) => + Object.values( + createAdminStepsConfig({ + t, + actions: { + saveAdminState: admin.saveAdminState, + openConfigModal: admin.openConfigModal, + navigateToSection: admin.navigateToSection, + scrollNavToSection: admin.scrollNavToSection, + }, + }), + ), + }, + tools: { + id: "tools", + build: ({ t, workbench, admin, openFilesModal, closeFilesModal }) => + Object.values( + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + selectCropTool: workbench.selectCropTool, + loadSampleFile: workbench.loadSampleFile, + switchToActiveFiles: workbench.switchToActiveFiles, + pinFile: workbench.pinFile, + revealFileCardHoverMenu: workbench.revealFileCardHoverMenu, + modifyCropSettings: workbench.modifyCropSettings, + executeTool: workbench.executeTool, + openFilesModal, + openSettingsHelpSection: () => admin.navigateToSection("help"), + }, + }), + ), + }, + whatsnew: { + id: "whatsnew", + build: ({ t, workbench, openFilesModal, closeFilesModal }) => + Object.values( + createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + openFilesModal, + loadSampleFile: workbench.loadSampleFile, + switchToViewer: workbench.switchToViewer, + switchToPageEditor: workbench.switchToPageEditor, + switchToActiveFiles: workbench.switchToActiveFiles, + }, + }), + ), + }, +}; + +/** Default tour when a requested id is unknown or unset. */ +export const DEFAULT_TOUR_TYPE = "whatsnew"; + +/** Resolves a tour's steps, falling back to the default tour for unknown ids. */ +export function getTourSteps( + tourType: string, + ctx: TourBuildContext, +): StepType[] { + const definition = + TOUR_REGISTRY[tourType] ?? TOUR_REGISTRY[DEFAULT_TOUR_TYPE]; + return definition.build(ctx); +} diff --git a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts index e8a52803da..85281e712e 100644 --- a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts @@ -106,7 +106,7 @@ export function createUserStepsConfig({ actionAfter: () => openFilesModal(), }, [TourStep.FILE_SOURCES]: { - selector: '[data-tour="file-sources"]', + selector: '[data-tour="files-modal"]', content: t( "onboarding.fileSources", "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.", @@ -114,8 +114,8 @@ export function createUserStepsConfig({ position: "right", padding: 0, action: async () => { - await waitForElement('[data-tour="file-sources"]', 5000); - await waitForHighlightable('[data-tour="file-sources"]', 5000); + await waitForElement('[data-tour="files-modal"]', 5000); + await waitForHighlightable('[data-tour="files-modal"]', 5000); }, actionAfter: () => { loadSampleFile(); @@ -135,7 +135,7 @@ export function createUserStepsConfig({ selector: '[data-tour="workbench"]', content: t( "onboarding.activeFiles", - "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.", + "The Active Files view shows all of the PDFs you have loaded into the tool.", ), position: "center", padding: 0, @@ -145,7 +145,7 @@ export function createUserStepsConfig({ selector: '[data-tour="file-card-checkbox"]', content: t( "onboarding.fileCheckbox", - "Clicking one of the files selects it for processing. You can select multiple files for batch operations.", + "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar.", ), position: "top", padding: 10, @@ -200,17 +200,17 @@ export function createUserStepsConfig({ actionAfter: () => pinFile(), }, [TourStep.WRAP_UP]: { - selector: '[data-tour="admin-help-nav"]', + selector: '[data-tour="settings-modal"]', content: t( "onboarding.wrapUp", "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help.", ), - position: "right", - padding: 10, + position: "center", + padding: 0, action: async () => { openSettingsHelpSection(); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); + await waitForElement('[data-tour="settings-modal"]', 5000); + await waitForHighlightable('[data-tour="settings-modal"]', 5000); }, }, }; diff --git a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx deleted file mode 100644 index 9d9cbed2e2..0000000000 --- a/frontend/editor/src/core/components/policies/PoliciesSidebar.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Core stubs for the right-rail Policies UI. - * - * The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx} - * and shadow these stubs via the {@code @app/*} alias cascade when the proprietary - * build is active. Core builds render nothing, so the right rail shows only the - * tool list unchanged. - */ - -import type { ReactNode } from "react"; - -/** Whether the right rail should host the Policies section. False in core. */ -export function usePoliciesEnabled(): boolean { - return false; -} - -/** Whether the Policies list should appear for the current user. False in core. */ -export function usePoliciesVisible(): boolean { - return false; -} - -/** - * Whether a policy is open (its detail should take over the rail). Always false - * in core; proprietary bridges to the policy-selection store. - */ -export function usePolicyDetailActive(): boolean { - return false; -} - -/** Collapsible policy list rendered above the Tools section. Null in core. */ -export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) { - return null; -} - -/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */ -export function PolicyDetailTakeover() { - return null; -} - -/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */ -export function PoliciesCollapsedButton(_props: { onExpand: () => void }) { - return null; -} diff --git a/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts new file mode 100644 index 0000000000..d8bb52e159 --- /dev/null +++ b/frontend/editor/src/core/components/policies/usePoliciesEnabled.ts @@ -0,0 +1,8 @@ +/** + * Core stub — whether policy enforcement is active for this build. Gates + * mounting the headless PolicyAutoRunController. Always false in core; the + * proprietary and desktop builds shadow this via the {@code @app/*} alias. + */ +export function usePoliciesEnabled(): boolean { + return false; +} diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 485403c512..83f7342a74 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -266,7 +266,7 @@ const AppConfigModalInner: React.FC = ({ styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }} removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }} > -
+
{/* Left navigation */}
= { + title: "Shared/ButtonSelector", + component: ButtonSelector, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +function ModeDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState("automatic"); + return ( + + ); +} + +/** Enabled (files present): the selected segment must be dark + readable. */ +export const RedactionMode: Story = { render: () => }; + +/** Both options disabled (no files) — the selected segment must STILL be legible. */ +export const DisabledOptions: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css index 7d9b0251db..89c57dd3fa 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.module.css @@ -224,6 +224,7 @@ /* ── Individual file row ─────────────────────────── */ .fileItem { width: 100%; + --sui-btn-py: 0.625rem; padding: 0.625rem 0.875rem; display: flex; align-items: center; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index b7b7b277e3..b5c815f824 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -56,6 +56,7 @@ import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerMo import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal"; import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog"; +import { SidebarChecklistSlot } from "@app/components/shared/SidebarChecklistSlot"; import { deleteServerFile, type DeleteScope, @@ -1321,6 +1322,9 @@ const FileSidebar = forwardRef( onConfirm={handleConfirmSidebarDelete} /> + {/* Getting-started checklist, floating above the footer (SaaS only). */} + + {/* Bottom bar: user name + settings */} )}
- { - e.stopPropagation(); - onEyeClick(fileId, e); - }} - tabIndex={-1} - aria-label={ - isViewedInViewer - ? t("fileSidebar.fileItem.closeViewer", "Close viewer") - : t("fileSidebar.fileItem.openInViewer", "Open in viewer") - } - > - - - - {(onDelete || - (canSaveToCloud && onSaveToCloud) || - (hasVersionHistory && onVersionHistory)) && ( - - - e.stopPropagation()} - tabIndex={-1} - aria-label={t( - "fileSidebar.fileItem.moreActions", - "More actions", - )} - > - - - - e.stopPropagation()}> - {hasVersionHistory && onVersionHistory && ( - } - onClick={(e) => { - e.stopPropagation(); - onVersionHistory(fileId); - }} +
+ { + e.stopPropagation(); + onEyeClick(fileId, e); + }} + tabIndex={-1} + aria-label={ + isViewedInViewer + ? t("fileSidebar.fileItem.closeViewer", "Close viewer") + : t("fileSidebar.fileItem.openInViewer", "Open in viewer") + } + > + + + + {(onDelete || + (canSaveToCloud && onSaveToCloud) || + (hasVersionHistory && onVersionHistory)) && ( + + + e.stopPropagation()} + tabIndex={-1} + aria-label={t( + "fileSidebar.fileItem.moreActions", + "More actions", + )} > - {t("fileSidebar.fileItem.versionHistory", "Version history")} - - )} - {canSaveToCloud && - onSaveToCloud && - (() => { - const uploadLabel = isUploadedToCloud - ? t( - "fileSidebar.fileItem.updateOnServer", - "Update on server", - ) - : t( - "fileSidebar.fileItem.uploadToServer", - "Upload to server", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onSaveToCloud(fileId); - }} - > - {uploadLabel} - -
-
- ); - })()} - {onDelete && - (() => { - const deleteLabel = t( - "fileSidebar.fileItem.delete", - "Delete", - ); - return ( - -
- - } - onClick={(e) => { - e.stopPropagation(); - onDelete(fileId); - }} - > - {deleteLabel} - -
-
- ); - })()} - -
- )} + + + + e.stopPropagation()}> + {hasVersionHistory && onVersionHistory && ( + } + onClick={(e) => { + e.stopPropagation(); + onVersionHistory(fileId); + }} + > + {t( + "fileSidebar.fileItem.versionHistory", + "Version history", + )} + + )} + {canSaveToCloud && + onSaveToCloud && + (() => { + const uploadLabel = isUploadedToCloud + ? t( + "fileSidebar.fileItem.updateOnServer", + "Update on server", + ) + : t( + "fileSidebar.fileItem.uploadToServer", + "Upload to server", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onSaveToCloud(fileId); + }} + > + {uploadLabel} + +
+
+ ); + })()} + {onDelete && + (() => { + const deleteLabel = t( + "fileSidebar.fileItem.delete", + "Delete", + ); + return ( + +
+ + } + onClick={(e) => { + e.stopPropagation(); + onDelete(fileId); + }} + > + {deleteLabel} + +
+
+ ); + })()} +
+
+ )} +
{useRasterThumb && diff --git a/frontend/editor/src/core/components/shared/LandingPage.css b/frontend/editor/src/core/components/shared/LandingPage.css index a785418877..8ac150b0e5 100644 --- a/frontend/editor/src/core/components/shared/LandingPage.css +++ b/frontend/editor/src/core/components/shared/LandingPage.css @@ -5,7 +5,7 @@ /* ── Hero text ───────────────────────────────────────────── */ .landing-title { - margin: 1.75rem 0 0.5rem; + margin: 1.75rem 0 1.5rem; text-align: center; font-size: 2rem; font-weight: 700; diff --git a/frontend/editor/src/core/components/shared/LandingPage.tsx b/frontend/editor/src/core/components/shared/LandingPage.tsx index efb54cd7b6..cd3042fa7d 100644 --- a/frontend/editor/src/core/components/shared/LandingPage.tsx +++ b/frontend/editor/src/core/components/shared/LandingPage.tsx @@ -82,13 +82,6 @@ const LandingPage = () => {

{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}

-

- {t( - "landing.heroSubtitle", - "Drop in or add an existing PDF to get started.", - )} -

- void handleNativeUploadClick()} diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.tsx index dced852150..c861fd04b0 100644 --- a/frontend/editor/src/core/components/shared/LoadingFallback.tsx +++ b/frontend/editor/src/core/components/shared/LoadingFallback.tsx @@ -10,7 +10,9 @@ export function LoadingFallback() { alignItems: "center", height: "100vh", fontSize: "18px", - color: "#666", + // Theme-aware so the splash follows light/dark instead of forcing white. + backgroundColor: "var(--mantine-color-body)", + color: "var(--mantine-color-text)", }} > Loading... diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx index 9fb7f7c925..9329b0d6d8 100644 --- a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx @@ -16,6 +16,7 @@ import remarkGfm from "remark-gfm"; import apiClient from "@app/services/apiClient"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAuth } from "@app/auth/UseSession"; +import { withBasePath } from "@app/constants/app"; import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex"; const ACCEPTED_STORAGE_KEY = "loginAgreementAccepted"; @@ -144,7 +145,7 @@ export default function LoginAgreementModal() { } catch { /* ignore */ } - window.location.assign("/login"); + window.location.assign(withBasePath("/login")); } else { // Anonymous / desktop: best-effort close the window; if that is a no-op (web), // reload so the agreement re-blocks until accepted. diff --git a/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx new file mode 100644 index 0000000000..d9844123d6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx @@ -0,0 +1,13 @@ +export interface SidebarChecklistSlotProps { + /** Whether the sidebar is collapsed to its narrow rail. */ + collapsed?: boolean; +} + +/** + * Extension point for a getting-started checklist that floats above the + * sidebar footer. Core renders nothing; builds that offer onboarding (SaaS) + * shadow this file to provide the real checklist. + */ +export function SidebarChecklistSlot(_props: SidebarChecklistSlotProps) { + return null; +} diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index 4333e263f1..41110ac034 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -5,14 +5,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext"; import { useIsMobile } from "@app/hooks/useIsMobile"; import ToolPanel from "@app/components/tools/ToolPanel"; import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; -import { - PoliciesCollapsedButton, - PoliciesSection, - PolicyDetailTakeover, - usePoliciesEnabled, - usePoliciesVisible, - usePolicyDetailActive, -} from "@app/components/policies/PoliciesSidebar"; +import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled"; import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController"; import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import { useToolSections } from "@app/hooks/useToolSections"; @@ -66,8 +59,6 @@ export default function RightSidebar() { } = useToolWorkflow(); const policiesEnabled = usePoliciesEnabled(); - const policiesVisible = usePoliciesVisible(); - const rawPolicyDetailActive = usePolicyDetailActive(); const fullscreenExpanded = useIsFullscreenExpanded(); const fullscreenGeometry = useToolPanelGeometry({ enabled: fullscreenExpanded, @@ -100,39 +91,12 @@ export default function RightSidebar() { }); }; - // Opening a policy (e.g. from the collapsed rail) lands the rail in the clean - // default tool-picker view — the only view the policy takeover renders in — so - // it never collides with an open tool or the all-tools/search view. - const handleOpenPolicy = () => { - withViewTransition(() => { - if (readerMode) setReaderMode(false); - setLeftPanelView("toolPicker"); - if (!sidebarsVisible) setSidebarsVisible(true); - setAllToolsView(false); - setSearchQuery(""); - }); - }; - // The header shows [back] [search] when we have somewhere to go back to — // i.e. the user is in a specific tool, or already in the all-tools/search view. const inToolView = leftPanelView !== "toolPicker"; // Show X (close) button only when there's somewhere to go back to. const showCloseButton = inToolView || allToolsView; - // Policies sit above the tool list in the default tool-picker view — but only - // when the current user actually has policies to see (see usePoliciesVisible), - // so regular users with none get the plain tool picker with no empty block. - const showPolicies = - policiesEnabled && - policiesVisible && - !allToolsView && - leftPanelView === "toolPicker"; - // When Policies are shown, the search moves OUT of the header to sit between - // the Policies and Tools sections (separating them); otherwise it stays in the - // header. Show the header search when there's a close button, or in the - // default tool-picker view. - const showInlineSearch = showPolicies && !showCloseButton; - const showHeaderSearch = - !showInlineSearch && (showCloseButton || leftPanelView === "toolPicker"); + const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker"; const handleHeaderBack = () => { if (inToolView) { @@ -165,15 +129,7 @@ export default function RightSidebar() { ? (toolRegistry[selectedToolKey as ToolId] ?? null) : null; - // The detail takeover replaces the tool list ONLY in the same default view — - // never over an open tool or the all-tools view (which must keep priority). - // A lingering selection is harmless: it stays hidden behind a tool and the - // list/takeover reappears on return to the picker (as in the prototype). - const policyDetailActive = rawPolicyDetailActive && showPolicies; - - // The rail widens when a policy detail takes it over — the tool list is fine - // at 18.5rem, but the policy detail/wizard/settings need more breathing room. - const expandedWidth = policyDetailActive ? "25rem" : "18.5rem"; + const expandedWidth = "18.5rem"; const computedWidth = () => { if (isMobile) return "100%"; @@ -236,9 +192,6 @@ export default function RightSidebar() {
- {policiesEnabled && ( - - )}
{collapsedRailItems.map(({ id, tool }) => ( - {policyDetailActive ? ( -
- -
- ) : ( - <> - {!showPolicies && - (activeTool ? ( - - } - title={activeTool.name} - onClose={handleHeaderBack} - closeLabel={ + <> + {activeTool ? ( + + } + title={activeTool.name} + onClose={handleHeaderBack} + closeLabel={ + inToolView + ? t("toolPanel.backToAllTools", "Back to all tools") + : t("toolPanel.goBack", "Go back") + } + /> + ) : ( +
+ {showHeaderSearch ? ( +
+ +
+ ) : null} + {showCloseButton ? ( + + className="tool-panel__expand-btn" + > + + ) : ( -
- {showHeaderSearch ? ( -
- -
- ) : null} - {showCloseButton ? ( - - - - ) : ( - - - - )} -
- ))} + + + + )} +
+ )} - {showPolicies && ( - - - - } - /> - )} - - {showInlineSearch && ( -
- -
- )} - - - - )} + +
)} diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index a8ad41588f..e9cbb9b1b2 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -511,7 +511,8 @@ flex-direction: row; gap: 0.75rem; align-items: flex-start; - padding: 0.85rem 0.95rem; + --sui-btn-py: 1rem; + padding: 1rem 0.95rem; border: 1px solid var(--fullscreen-border-subtle-70); border-radius: 0.95rem; background: var(--fullscreen-bg-item); @@ -526,6 +527,11 @@ min-width: 0; } +.tool-panel__fullscreen-item .mantine-Button-label { + gap: 1rem; + align-items: flex-start; +} + .tool-panel__fullscreen-item:focus-visible { outline: 2px solid var(--fullscreen-accent-selected); outline-offset: 3px; diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx index 27b0caf7d5..779e945b8c 100644 --- a/frontend/editor/src/core/components/tools/ToolPicker.tsx +++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx @@ -197,7 +197,7 @@ const ToolPicker = ({ ) : ( <> {/* All-tools view: favourites + recommended + all subcategories. */} - + {favoriteToolItems.length > 0 && (
diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx index 88f2d72f6c..9311392beb 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx @@ -12,6 +12,7 @@ import { import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; type Props = { parameters: AddStampParameters; @@ -29,6 +30,7 @@ export default function StampPreview({ file, showQuickGrid, }: Props) { + const { t } = useTranslation(); const containerRef = useRef(null); const [containerSize, setContainerSize] = useState<{ width: number; @@ -366,7 +368,9 @@ export default function StampPreview({
-
Preview Stamp
+
+ {t("addStamp.preview", "Preview Stamp")} +
{ return ( - -
- {/* First row - PKCS#12 and PFX */} -
- - -
- {/* Second row - PEM and JKS */} -
- - -
-
-
+ + {FORMATS.map((format) => ( +
) : ( @@ -526,7 +539,7 @@ export default function HomePage() { setConfigModalOpen(false)} + onClose={handleCloseConfig} /> )} diff --git a/frontend/editor/src/core/services/preferencesService.ts b/frontend/editor/src/core/services/preferencesService.ts index bba1eadc22..a378136665 100644 --- a/frontend/editor/src/core/services/preferencesService.ts +++ b/frontend/editor/src/core/services/preferencesService.ts @@ -21,12 +21,18 @@ export type ViewerZoomSetting = | "150" | "200"; +// SaaS-only: which app a team lead lands on after signing in. Members can't +// reach the processor, so this never applies to them. +export type LoginLandingView = "processor" | "editor"; + export interface UserPreferences { autoUnzip: boolean; autoUnzipFileLimit: number; defaultToolPanelMode: ToolPanelMode; defaultStartupView: StartupView; defaultViewerZoom: ViewerZoomSetting; + // SaaS-only: team lead's post-login landing app (processor vs editor). + loginLandingView: LoginLandingView; theme: ThemeMode; toolPanelModePromptSeen: boolean; hasSelectedToolPanelMode: boolean; @@ -46,6 +52,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE, defaultStartupView: "tools", defaultViewerZoom: "auto", + loginLandingView: "processor", theme: "system", toolPanelModePromptSeen: false, hasSelectedToolPanelMode: false, diff --git a/frontend/editor/src/core/styles/theme.css b/frontend/editor/src/core/styles/theme.css index 831954eff4..c02965cf0b 100644 --- a/frontend/editor/src/core/styles/theme.css +++ b/frontend/editor/src/core/styles/theme.css @@ -442,10 +442,10 @@ --gray-800: 218 222 245; --gray-900: 238 241 252; - /* Dark semantic colors for Tailwind */ - --surface: 19 23 41; - --background: 9 11 24; - --border: 28 35 64; + /* Dark semantic colors for Tailwind — aligned with portal tokens.css */ + --surface: 21 28 46; /* #151c2e — portal --color-surface */ + --background: 9 12 20; /* #090c14 — portal --color-bg */ + --border: 40 50 72; /* #283248 — portal --color-border */ /* Dark theme Mantine colors */ --color-red-50: #2d1b1b; @@ -524,14 +524,18 @@ --color-yellow-800: #fef08a; --color-yellow-900: #fef9c3; - /* Dark theme semantic colors — deep navy/indigo palette */ - --bg-surface: #131729; - --bg-raised: #0d1020; - --bg-muted: #0d1020; - --bg-background: #090b18; - --bg-toolbar: #090b18; - --bg-file-manager: #090b18; - --bg-file-list: #131729; + /* Dark theme semantic colors — deep navy/indigo palette, aligned with portal + tokens.css so the editor and portal (Processor) dark modes don't drift. + Chrome surfaces (--bg-toolbar, --bg-file-manager) sit at the portal's + sidebar level (#0d1120) so the left/right sidebars lift off the darker + canvas (--bg-background) instead of blending into it. */ + --bg-surface: #151c2e; /* portal --color-surface */ + --bg-raised: #0d1120; /* portal --color-bg-alt */ + --bg-muted: #0d1120; + --bg-background: #090c14; /* portal --color-bg */ + --bg-toolbar: #0d1120; /* portal --color-sidebar-bg — lifted off the canvas */ + --bg-file-manager: #0d1120; /* portal --color-sidebar-bg */ + --bg-file-list: #151c2e; /* portal --color-surface */ --btn-open-file: #4f8ef5; --text-primary: #e8eaf6; --text-secondary: #9299b0; @@ -540,11 +544,11 @@ --text-always-dark: #1f2937; --text-always-dark-muted: #6b7280; --border-subtle: rgba(255, 255, 255, 0.05); - --border-default: #1c2340; - --border-strong: #2d3560; - --border-hover: #3a4680; - --hover-bg: #181d38; - --active-bg: #232a50; + --border-default: #1e2840; /* portal --color-border-light */ + --border-strong: #283248; /* portal --color-border */ + --border-hover: #3d4f6a; /* portal --color-border-hover */ + --hover-bg: #1c2640; /* portal --color-bg-hover */ + --active-bg: #243044; /* portal --color-bg-muted */ --automation-entry-hover-bg: var(--color-gray-200); /* Icon colors for dark mode */ @@ -615,7 +619,7 @@ --header-selected-bg: #2040a0; --header-selected-fg: #ffffff; /* file card background (dark) */ - --file-card-bg: #0d1020; + --file-card-bg: #0d1120; --accordion-item-bg: #1c2340; /* accordion item background - more distinguishable */ /* shadows */ diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index db715220d3..c66f392fa1 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from "@app/tests/helpers/stub-test-base"; -import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs"; import { openSettings } from "@app/tests/helpers/ui-helpers"; test.describe("2. Main Dashboard / Home Page", () => { @@ -113,40 +112,4 @@ test.describe("2. Main Dashboard / Home Page", () => { }); }); }); - - test.describe("2.5 Dashboard - Welcome Dialog for fresh users", () => { - test("should show welcome dialog when onboarding flags are unset", async ({ - browser, - }) => { - // Fresh context — no localStorage flags, so the onboarding modal should appear. - const context = await browser.newContext({ - viewport: { width: 1920, height: 1080 }, - }); - const page = await context.newPage(); - - await seedCookieConsent(page); - await mockAppApis(page); - await page.goto("/"); - - const welcomeDialog = page.getByText(/welcome/i).first(); - await expect(welcomeDialog).toBeVisible({ timeout: 10000 }); - - for (let i = 0; i < 5; i++) { - const hasOverlay = await page - .locator(".mantine-Modal-overlay, .mantine-Overlay-root") - .first() - .isVisible() - .catch(() => false); - if (!hasOverlay) break; - await page.keyboard.press("Escape"); - await page.waitForTimeout(500); - } - - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ - timeout: 10000, - }); - - await context.close(); - }); - }); }); diff --git a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts b/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts deleted file mode 100644 index 7e60a8c189..0000000000 --- a/frontend/editor/src/core/tests/stubbed/policy-admin-gate.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from "@app/tests/helpers/stub-test-base"; - -/** - * Policy editing is admin-only (mirrors the backend's enforcement on the - * save/delete endpoints). The frontend gate is `canConfigure = !enableLogin || - * isAdmin`, surfaced through `usePolicies()`: - * - login enabled + non-admin → read-only: the setup wizard shows the - * "Managed by your organization" locked state instead of the steps. - * - login enabled + admin → full setup flow ("Step 1 of 2"). - * - login disabled (single-user/desktop) → open to the local operator. - * - * Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy - * list are stubbed via `mockAppApis`, so this asserts the gating wiring without - * a live Spring Boot server. "Security" is the only non-coming-soon policy, so - * it's the one we open. - */ - -const LOCKED_TITLE = "Managed by your organization"; -const LOCKED_DESC = "Contact a team leader to change this policy."; - -/** Open the Security policy from the right-sidebar Policies list. */ -async function openSecurityPolicy(page: import("@playwright/test").Page) { - const row = page.locator("button.pol-row").filter({ hasText: "Security" }); - await expect(row).toBeVisible({ timeout: 15_000 }); - await row.click(); - // The wizard header confirms we opened the right policy in either state. - await expect(page.getByText("Set up Security Policy")).toBeVisible(); -} - -// Policies are a SaaS-only feature: POLICIES_ENABLED is off in the proprietary -// and core builds this stubbed suite runs against, so the policy UI never -// renders here. Skip unless the app under test is built with policies enabled -// and the runner opts in via POLICIES_E2E=1. -test.beforeEach(() => { - const enabled = ["1", "true"].includes(process.env.POLICIES_E2E ?? ""); - test.skip(!enabled, "Policies are SaaS-only; set POLICIES_E2E=1 to run"); -}); - -test.describe("Policy editing gate — non-admin (login on)", () => { - test.use({ - stubOptions: { enableLogin: true, isAdmin: false }, - seedJwt: true, - }); - - test("non-admin gets the read-only locked state", async ({ page }) => { - await openSecurityPolicy(page); - await expect(page.getByText(LOCKED_TITLE)).toBeVisible(); - await expect(page.getByText(LOCKED_DESC)).toBeVisible(); - // The editable flow must NOT be reachable. - await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0); - }); -}); - -test.describe("Policy editing gate — admin (login on)", () => { - test.use({ - stubOptions: { enableLogin: true, isAdmin: true }, - seedJwt: true, - }); - - test("admin can reach the setup wizard", async ({ page }) => { - await openSecurityPolicy(page); - await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible(); - await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0); - }); -}); - -test.describe("Policy editing gate — single-user (login off)", () => { - test.use({ stubOptions: { enableLogin: false, isAdmin: false } }); - - test("local operator can reach the setup wizard with no admin role", async ({ - page, - }) => { - await openSecurityPolicy(page); - await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible(); - await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0); - }); -}); diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index 374c3c1a79..743142e820 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -370,22 +370,5 @@ export const mantineTheme = createTheme({ }, }, }, - - SegmentedControl: { - styles: { - root: { - backgroundColor: "var(--bg-muted)", - border: "1px solid var(--border-subtle)", - }, - control: { - color: "var(--text-secondary)", - "&[dataActive]": { - backgroundColor: "var(--bg-surface)", - color: "var(--text-primary)", - boxShadow: "var(--shadow-sm)", - }, - }, - }, - }, }, }); diff --git a/frontend/editor/src/core/tools/Compare.tsx b/frontend/editor/src/core/tools/Compare.tsx index b555ecfb9b..d7d6e0f65b 100644 --- a/frontend/editor/src/core/tools/Compare.tsx +++ b/frontend/editor/src/core/tools/Compare.tsx @@ -498,32 +498,27 @@ const Compare = (props: BaseToolProps) => { {t("compare.original.label", "Original PDF")} -
- - {renderSlot("base")} - - {t("compare.edited.label", "Edited PDF")} - - {renderSlot("comparison")} - + + {renderSlot("base")} {hasBothSelected && ( )} -
+ + + {t("compare.edited.label", "Edited PDF")} + + {renderSlot("comparison")} + = () => { + const { t } = useTranslation(); useEffect(() => { // Redirect to Swagger UI window.open(withBasePath("/swagger-ui/5.21.0/index.html"), "_blank"); @@ -10,15 +12,15 @@ const SwaggerUI: React.FC = () => { return (
-

Opening Swagger UI in a new tab...

+

{t("swagger.opening", "Opening Swagger UI in a new tab...")}

- If it didn't open automatically,{" "} + {t("swagger.fallback", "If it didn't open automatically,")}{" "} - click here + {t("swagger.clickHere", "click here")}

diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index a99f990f33..bf1a8f5fe6 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -204,7 +204,7 @@ const FormFill = (_props: BaseToolProps) => { setTimeout(() => URL.revokeObjectURL(url), 250); } catch (err) { console.error("[FormFill] CSV extraction failed:", err); - setSaveError("Failed to extract CSV"); + setSaveError(t("formFill.extractCsvError", "Failed to extract CSV")); } finally { setExtracting(false); } @@ -223,7 +223,7 @@ const FormFill = (_props: BaseToolProps) => { setTimeout(() => URL.revokeObjectURL(url), 250); } catch (err) { console.error("[FormFill] XLSX extraction failed:", err); - setSaveError("Failed to extract XLSX"); + setSaveError(t("formFill.extractXlsxError", "Failed to extract XLSX")); } finally { setExtracting(false); } @@ -246,7 +246,9 @@ const FormFill = (_props: BaseToolProps) => { if (!currentFile || !isStirlingFile(currentFile)) return; if (!validateForm()) { - setSaveError("Please fill in all required fields"); + setSaveError( + t("formFill.requiredFieldsError", "Please fill in all required fields"), + ); return; } @@ -641,7 +643,9 @@ const FormFill = (_props: BaseToolProps) => { {field.label || field.name} {field.required && ( - req + + {t("formFill.requiredAbbreviation", "req")} + )}
@@ -683,10 +687,10 @@ const FormFill = (_props: BaseToolProps) => { )} {formState.isDirty || flattenChanged - ? "Unsaved changes" - : "All saved"} + ? t("formFill.unsavedChanges", "Unsaved changes") + : t("formFill.allSaved", "All saved")} - Ctrl+S to save + {t("formFill.saveShortcut", "Ctrl+S to save")}
)} diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index 5592711979..e80bf13222 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -35,7 +35,37 @@ white-space: normal; } +.sui-btn .mantine-Button-label { + line-height: 1.4; +} + +/* ---- height: --button-height is a minimum, not a cap ---- */ +/* Single-line content computes to exactly --button-height (same as ActionIcon / + SegmentedControl via CONTROL_HEIGHT); taller content (wrapped label, stacked + rows) grows the button instead of clipping mid-glyph. Under height:auto the + inner's height:100% resolves to auto on its own, so block-level + align-content re-centers short content — deliberately NOT flex on the root: + that display would out-specificity consumers' own display (e.g. a + width:100% list row) and turn them inline-level. */ +.sui-btn.mantine-Button-root { + height: auto; + min-height: var(--button-height); + align-content: center; + /* Vertical padding via the `py` prop; Mantine has no --button-padding-y. + Default 0 keeps height-driven layout unchanged for every other button. */ + padding-block: var(--sui-btn-py, 0); +} + /* no-hover: !important to beat Mantine's own :hover background rule. */ .sui-btn--no-hover:not(:disabled):not([data-disabled]):hover { background: var(--button-bg, transparent) !important; } + +[data-theme="dark"] + .sui-btn--primary.mantine-Button-root:disabled:not([data-loading]), +[data-theme="dark"] + .sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) { + background: var(--button-bg); + color: var(--button-color); + opacity: 0.55; +} diff --git a/frontend/editor/src/core/ui/Button.stories.tsx b/frontend/editor/src/core/ui/Button.stories.tsx index a4c7239f89..d35a24aed7 100644 --- a/frontend/editor/src/core/ui/Button.stories.tsx +++ b/frontend/editor/src/core/ui/Button.stories.tsx @@ -72,6 +72,18 @@ const meta: Meta = { ], }, size: { control: "inline-radio", options: ["sm", "md", "lg", "xl"] }, + p: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + px: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, + py: { + control: "inline-radio", + options: [undefined, "none", "xs", "sm", "md", "lg", "xl"], + }, justify: { control: "inline-radio", options: ["center", "start", "end", "between"], @@ -141,6 +153,34 @@ export const Accents: Story = { ), }; +/** Loading keeps the button's shape. A labelless loader collapses to an icon + square — unless `fullWidth`, which must always span its container (e.g. an + execute button whose label is briefly absent while files hydrate). */ +export const Loading: Story = { + render: (args) => ( +
+
+ ), +}; + +/** Disabled primary — dark mode keeps a muted accent instead of grey. */ +export const DisabledDark: Story = { + render: () => ( +
+
+
+
+ ), +}; + /** Real size differences. */ export const Sizes: Story = { render: (args) => ( @@ -152,15 +192,12 @@ export const Sizes: Story = { ), }; -/** - * `fontSize` scales the label text independently of `size` (which sets height). - * Unset inherits the `size`-derived default (md for a default button). - */ -export const FontSizes: Story = { +/** Padding override via `p` (both axes); `px`/`py` override a single axis. */ +export const Padding: Story = { render: (args) => ( - {(["xs", "sm", "md", "lg", "xl"] as const).map((fontSize) => ( -
+ ); +} diff --git a/frontend/editor/src/core/ui/Chip.css b/frontend/editor/src/core/ui/Chip.css index f4b83cb853..b8acf99324 100644 --- a/frontend/editor/src/core/ui/Chip.css +++ b/frontend/editor/src/core/ui/Chip.css @@ -48,6 +48,17 @@ .sui-chip__label { display: inline-block; } + +/* Center the label precisely. Mantine sets line-height: var(--pill-height) on + the label, which positions the text by its font baseline and reads slightly + high; flex-centering with line-height:1 centres the ink in the pill. */ +.sui-chip .mantine-Pill-label { + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + overflow: visible; +} .sui-chip__spinner { width: 0.75em; height: 0.75em; diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index 1a05b23e4f..cff078057d 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -1,5 +1,6 @@ import { useState, type ReactNode } from "react"; import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; import "@app/ui/CodeBlock.css"; export type CodeLang = @@ -33,6 +34,7 @@ export function CodeBlock({ maxHeight = 400, className, }: CodeBlockProps) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); async function copy() { @@ -64,7 +66,7 @@ export function CodeBlock({ onClick={copy} aria-label="Copy code" > - {copied ? "Copied" : "Copy"} + {copied ? t("common.copied", "Copied!") : t("common.copy", "Copy")} )}
diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index 66551aedf5..fb95e15c07 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -24,6 +24,10 @@ font-size: 0.875rem; } +.sui-mantine-wrapper .mantine-Input-input { + font-size: 0.875rem; +} + /* SUI focus ring — matches .sui-input:focus-within */ .sui-mantine-wrapper[data-focused], .sui-mantine-wrapper:focus-within { diff --git a/frontend/editor/src/core/ui/SegmentedControl.css b/frontend/editor/src/core/ui/SegmentedControl.css index 62e05c1941..5f1d557fe3 100644 --- a/frontend/editor/src/core/ui/SegmentedControl.css +++ b/frontend/editor/src/core/ui/SegmentedControl.css @@ -13,9 +13,16 @@ display: flex; align-items: center; justify-content: center; + width: 100%; height: 100%; padding-block: 0; } + +.sui-seg .mantine-SegmentedControl-label[data-active][data-disabled] { + color: var(--mantine-color-text); + opacity: 1; +} + /* Mantine wraps label content in .innerLabel; flex it so an icon + text sit * centered together with a consistent gap. Without this the inline-block icon * aligns to the text baseline and rides ~3px high. */ diff --git a/frontend/editor/src/core/ui/SegmentedControl.tsx b/frontend/editor/src/core/ui/SegmentedControl.tsx index a82874cc45..aa5e1b4ff2 100644 --- a/frontend/editor/src/core/ui/SegmentedControl.tsx +++ b/frontend/editor/src/core/ui/SegmentedControl.tsx @@ -54,7 +54,7 @@ export function SegmentedControl({ value, onChange, accent = "default", - size = "md", + size = "sm", variant = "primary", fullWidth = false, disabled = false, @@ -72,11 +72,10 @@ export function SegmentedControl({ .filter(Boolean) .join(" "); - // Drive Mantine's indicator/label colour vars from the accent palette. const accentVars = - variant === "primary" - ? { "--sc-color": "var(--_solid)", "--sc-label-color": "var(--_on)" } - : { "--sc-color": "var(--_tint)", "--sc-label-color": "var(--_text)" }; + variant === "secondary" + ? { "--sc-color": "var(--_tint)", "--sc-label-color": "var(--_text)" } + : {}; return ( { + if (!document.title.startsWith(prefix)) { + // The app rewrites document.title on route/tool changes; re-apply the + // prefix on top of whatever the app just set. + document.title = prefix + document.title; + } + }; + + ensurePrefixed(); + + const titleEl = document.querySelector("title"); + if (titleEl) { + new MutationObserver(ensurePrefixed).observe(titleEl, { + childList: true, + characterData: true, + subtree: true, + }); + } +} diff --git a/frontend/editor/src/core/utils/settingsNavigation.ts b/frontend/editor/src/core/utils/settingsNavigation.ts index fddd9ab5bc..a4ca4248dd 100644 --- a/frontend/editor/src/core/utils/settingsNavigation.ts +++ b/frontend/editor/src/core/utils/settingsNavigation.ts @@ -8,6 +8,13 @@ export function navigateToSettings(section: NavKey) { window.dispatchEvent(new PopStateEvent("popstate")); } +export function openSettings(section: NavKey) { + window.history.pushState({}, "", withBasePath(`/settings/${section}`)); + window.dispatchEvent( + new CustomEvent("appConfig:open", { detail: { section } }), + ); +} + /** URL for a settings section (subpath-aware). */ export function getSettingsUrl(section: NavKey): string { return withBasePath(`/settings/${section}`); diff --git a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx index eef035bbd0..d667b20714 100644 --- a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx +++ b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx @@ -1,74 +1,19 @@ -import { useEffect, useState } from "react"; -import { useAuth } from "@app/auth/UseSession"; -import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; - /** * Desktop bootstrap for the SaaS product onboarding. * - * Mirrors saas's {@code OnboardingBootstrap}: once a SaaS user has signed in we - * show the shared cloud {@link SaasOnboardingModal} (free-editor pitch → usage - * meter → team), reusing the exact same flow as the web app. The closing - * "download desktop" slide is dropped via {@code hideDesktopInstall} — this IS - * the desktop app, so pitching its own download makes no sense. + * First-load auto-display is disabled: the shared cloud {@link SaasOnboardingModal} + * no longer appears automatically on first sign-in. This mirrors the web SaaS + * {@code OnboardingBootstrap}, which no longer auto-opens either. The modal + * component is retained for explicit/manual triggering; nothing opens it here. * - * Differences from the saas bootstrap: - * - The desktop {@code useAuth} (proprietary) exposes no pro/wallet fields; the - * cloud flow reads the live wallet itself to decide which slides to show, so - * we just wait for a non-anonymous signed-in user. - * - Gated on {@code connectionMode === "saas"} so it never fires in local or - * self-hosted mode (where there is no SaaS wallet/team to onboard). - * - * Shown once per device, gated by localStorage (same key the saas web flow uses - * so a user who onboarded on the web isn't re-onboarded — they are independent - * stores, but the key/intent is shared). + * The props signature is preserved so the AppProviders wiring is unaffected. */ -const STORAGE_KEY = "saas_onboarding_seen"; - interface DesktopSaasOnboardingBootstrapProps { connectionMode: "saas" | "selfhosted" | "local" | null; } -export function DesktopSaasOnboardingBootstrap({ - connectionMode, -}: DesktopSaasOnboardingBootstrapProps) { - const { user, loading } = useAuth(); - const [showModal, setShowModal] = useState(false); - - const isSignedInSaasUser = - connectionMode === "saas" && - !loading && - !!user && - user.is_anonymous !== true; - - useEffect(() => { - if (!isSignedInSaasUser) return; - let seen = false; - try { - seen = localStorage.getItem(STORAGE_KEY) === "true"; - } catch { - // localStorage unavailable — fail open and show onboarding. - } - if (!seen) { - setShowModal(true); - } - }, [isSignedInSaasUser]); - - const handleClose = () => { - try { - localStorage.setItem(STORAGE_KEY, "true"); - } catch { - // localStorage unavailable — best-effort; the in-memory flag still hides it. - } - setShowModal(false); - }; - - if (!showModal) return null; - - return ( - - ); +export function DesktopSaasOnboardingBootstrap( + _props: DesktopSaasOnboardingBootstrapProps, +) { + return null; } diff --git a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css index 979481bd45..6661c6a99b 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css +++ b/frontend/editor/src/desktop/components/SetupWizard/desktopOAuth.css @@ -13,7 +13,10 @@ display: flex; align-items: center; justify-content: flex-start; - padding: 0.75rem 1rem; /* 12px 16px */ + /* Vertical padding via the shared Button's --sui-btn-py (beats the Button's + own padding-block); horizontal stays in the shorthand. */ + --sui-btn-py: 1rem; /* 16px (md) */ + padding: 1rem 1rem; /* 16px */ border: 1px solid var(--auth-input-border-light-only); border-radius: 0.75rem; /* 12px */ background-color: var(--auth-card-bg-light-only); diff --git a/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx deleted file mode 100644 index c757b0d260..0000000000 --- a/frontend/editor/src/desktop/components/policies/PoliciesSidebar.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Desktop shadow of the proprietary Policies right-rail. - * - * Re-exports the proprietary module unchanged EXCEPT `usePoliciesEnabled`, which - * additionally requires an active SaaS connection. Policy runs execute + bill via - * the cloud (POST /api/v1/policies/.../run hits the SaaS backend), so on desktop - * the feature must be hidden in local ("disconnected") and self-hosted modes — - * otherwise the panel + auto-run controller would fire policy runs against a - * backend that doesn't serve them. On web the build flavor already gates it. - * - * `usePoliciesEnabled` is the single gate RightSidebar uses for BOTH the rail - * section and mounting PolicyAutoRunController, so this one override covers both. - */ -import { POLICIES_ENABLED } from "@app/constants/featureFlags"; -import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; - -export * from "@proprietary/components/policies/PoliciesSidebar"; - -export function usePoliciesEnabled(): boolean { - // Pessimistic SaaS-mode check (starts false): this gate also controls whether - // PolicyAutoRunController mounts, and that fires GET /api/v1/policies on mount. - // useSaaSMode()'s optimistic-true default would leak that request against the - // local/self-hosted backend on cold start before the mode resolves. - return POLICIES_ENABLED && useConfirmedSaaSMode(); -} diff --git a/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts new file mode 100644 index 0000000000..efc8f33fa7 --- /dev/null +++ b/frontend/editor/src/desktop/components/policies/usePoliciesEnabled.ts @@ -0,0 +1,19 @@ +import { POLICIES_ENABLED } from "@app/constants/featureFlags"; +import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode"; + +/** + * Desktop shadow: policy runs execute + bill via the cloud (POST + * /api/v1/policies/.../run hits the SaaS backend), so the feature must stay + * off in local ("disconnected") and self-hosted modes — otherwise the + * auto-run controller would fire policy runs against a backend that doesn't + * serve them. + * + * Pessimistic SaaS-mode check (starts false): this gate controls whether + * PolicyAutoRunController mounts, and that fires GET /api/v1/policies on + * mount. useSaaSMode()'s optimistic-true default would leak that request + * against the local/self-hosted backend on cold start before the mode + * resolves. + */ +export function usePoliciesEnabled(): boolean { + return POLICIES_ENABLED && useConfirmedSaaSMode(); +} diff --git a/frontend/editor/src/desktop/constants/featureFlags.ts b/frontend/editor/src/desktop/constants/featureFlags.ts index 957860b7cf..d003b3e642 100644 --- a/frontend/editor/src/desktop/constants/featureFlags.ts +++ b/frontend/editor/src/desktop/constants/featureFlags.ts @@ -2,8 +2,8 @@ * Desktop-build feature gates. Shadows `proprietary/constants/featureFlags.ts` * (the desktop `@app/*` alias has no saas layer). Re-exports the proprietary * flags and re-enables Policies: the desktop Policies gate additionally requires - * an active SaaS connection (see desktop PoliciesSidebar's `usePoliciesEnabled`), - * so the flag must be on for that runtime check to ever apply. + * an active SaaS connection (see the desktop `usePoliciesEnabled` shadow), so + * the flag must be on for that runtime check to ever apply. */ export * from "@proprietary/constants/featureFlags"; diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index d776918bd1..6ee0f6df51 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -14,9 +14,12 @@ import { BrowserRouter } from "react-router-dom"; import App from "@app/App"; import "@app/i18n"; // Initialize i18next import { BASE_PATH } from "@app/constants/app"; +import { applyDevWorktreeLabel } from "@app/utils/applyDevWorktreeLabel"; import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; +applyDevWorktreeLabel(); + if (typeof window !== "undefined") { const scheduleCompilation = () => { if (typeof requestIdleCallback === "function") { diff --git a/frontend/editor/src/portal-saas/api/fleetStats.ts b/frontend/editor/src/portal-saas/api/fleetStats.ts new file mode 100644 index 0000000000..9ba701f4c6 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/fleetStats.ts @@ -0,0 +1,16 @@ +import { apiClient } from "@portal/api/http"; +import type { FleetStats } from "@portal-proprietary/api/fleetStats"; + +export type { FleetStats }; + +/** + * SaaS build: fleet usage is team-scoped and served by the SaaS backend, so it is + * read via {@code apiClient.saas} — the admin's Supabase JWT, which the SaaS backend + * uses to resolve the caller's team. Shadows the self-hosted + * src/portal/api/fleetStats.ts (which reads the local backend server-wide). + */ +export function fetchFleetStats(signal?: AbortSignal): Promise { + return apiClient.saas.json("/api/v1/usage/fleet-stats", { + signal, + }); +} diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx index 851c011ae0..8ab2c58dcd 100644 --- a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx @@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { AuthProvider } from "@app/auth"; import { useAuth } from "@app/auth/context"; import { Spinner } from "@app/ui"; +import { withBasePath } from "@app/constants/app"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { EDITOR_URL } from "@portal/auth/editorUrl"; @@ -34,7 +35,7 @@ function SaasPortalGate({ children }: { children: ReactNode }) { useEffect(() => { if (!blocked) return; // Guest (has a session but anonymous) -> editor; no session -> login. - window.location.href = session ? EDITOR_URL : "/login"; + window.location.href = session ? EDITOR_URL : withBasePath("/login"); }, [blocked, session]); if (loading || blocked) { return ( diff --git a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx index b02c485581..73c3eba8f3 100644 --- a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx +++ b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx @@ -1,3 +1,4 @@ +import { withBasePath } from "@app/constants/app"; import { Usage } from "@portal/views/Usage"; /** @@ -13,7 +14,7 @@ export function PortalBillingGate() { return ( { - window.location.href = "/login"; + window.location.href = withBasePath("/login"); }} /> ); diff --git a/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts b/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts new file mode 100644 index 0000000000..093be2d9da --- /dev/null +++ b/frontend/editor/src/portal-saas/components/pipelines/outputModes.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +// Resolves to the SaaS override (src/portal-saas) via the @portal cascade. +import { availableOutputModes } from "@portal/components/pipelines/outputModes"; + +describe("availableOutputModes (SaaS)", () => { + it("offers only s3: no server filesystem, and inline results would expire unseen", () => { + expect(availableOutputModes()).toEqual(["s3"]); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts b/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts new file mode 100644 index 0000000000..c55ce3f487 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/pipelines/outputModes.ts @@ -0,0 +1,12 @@ +import type { PipelineOutputMode } from "@portal/api/pipelines"; + +/** + * Hosted deployments never write to the server's filesystem (the backend's + * FolderAccessGuard denies it outright), so folder outputs are not offered in + * the pipeline builder. Inline is not offered either: inline results live in + * transient job storage with no portal download surface, so for an unattended + * pipeline they would simply expire unseen. + */ +export function availableOutputModes(): PipelineOutputMode[] { + return ["s3"]; +} diff --git a/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts new file mode 100644 index 0000000000..1eec2ac959 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +// Resolves to the SaaS override (src/portal-saas) via the @portal cascade. +import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; + +describe("creatableSourceTypes (SaaS)", () => { + it("never offers folder sources: hosted deployments do not read the server filesystem", () => { + expect(creatableSourceTypes().map((t) => t.type)).not.toContain("folder"); + }); + + it("still offers the cloud source types", () => { + expect(creatableSourceTypes().map((t) => t.type)).toContain("s3"); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts new file mode 100644 index 0000000000..9fa73be0ff --- /dev/null +++ b/frontend/editor/src/portal-saas/components/sources/creatableSourceTypes.ts @@ -0,0 +1,13 @@ +import { + CREATABLE_SOURCE_TYPES, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; + +/** + * Hosted deployments never read the server's filesystem (the backend's + * FolderAccessGuard denies it outright), so folder connections are not offered + * in the connect wizard. + */ +export function creatableSourceTypes(): CreatableSourceType[] { + return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder"); +} diff --git a/frontend/editor/src/portal/api/documents.ts b/frontend/editor/src/portal/api/documents.ts index cbb109ec30..9e75f40ec7 100644 --- a/frontend/editor/src/portal/api/documents.ts +++ b/frontend/editor/src/portal/api/documents.ts @@ -4,8 +4,8 @@ import type { Tier } from "@portal/contexts/TierContext"; export type DocumentStatus = "processed" | "flagged" | "in-review" | "error"; -/** Which Stirling product ran the operation. */ -export type ProductType = "API" | "Editor"; +/** Which Stirling product ran the operation. "Automation" = a policy/pipeline run's tool step. */ +export type ProductType = "API" | "Editor" | "Automation"; /** A single field pulled out of the document by extraction. */ export interface Extraction { @@ -98,6 +98,7 @@ export const DOCUMENT_STATUS_TONE: Record = { export const PRODUCT_CHIP_TONE: Record = { API: "brand", Editor: "success", + Automation: "warning", }; /** Classification chip accent: danger when unclassified, warning when it needs a look. */ diff --git a/frontend/editor/src/portal/api/fleetStats.ts b/frontend/editor/src/portal/api/fleetStats.ts index b62526aacd..48bc247d84 100644 --- a/frontend/editor/src/portal/api/fleetStats.ts +++ b/frontend/editor/src/portal/api/fleetStats.ts @@ -4,9 +4,9 @@ import { apiClient } from "@portal/api/http"; * Free-editor fleet usage for the {@link FreePdfEditorsCard}. * * Self-hosted (this module) reads the local Stirling backend — the figures come - * from this instance's audit trail, filtered to free UI tool runs. A SaaS build - * shadows this module (src/saas/portal/api/fleetStats.ts) to read the - * team-scoped SaaS backend instead. + * from this instance's audit trail, filtered to free UI tool runs. The SaaS build + * shadows this module (src/portal-saas/api/fleetStats.ts) to read the team-scoped + * SaaS backend instead. * * Any field may be null when the backend can't compute it (e.g. EE auditing is * disabled); the card renders null as "N/A" rather than a misleading 0. diff --git a/frontend/editor/src/portal/api/infrastructure.ts b/frontend/editor/src/portal/api/infrastructure.ts index 073409e6c6..d624f67e03 100644 --- a/frontend/editor/src/portal/api/infrastructure.ts +++ b/frontend/editor/src/portal/api/infrastructure.ts @@ -169,6 +169,7 @@ export type AuditCategory = | "auth" | "config" | "elevation" + | "policy" | "processing" | "security"; @@ -187,6 +188,7 @@ export interface AuditEvent { export interface AuditSummary { totalEvents: number; + policy: number; processing: number; elevation: number; config: number; diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index 2fd5b2916e..e75711b912 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -29,6 +29,9 @@ export interface OutputSpec { options: Record; } +/** The output destinations the pipeline builder can offer. */ +export type PipelineOutputMode = "inline" | "folder" | "s3"; + /** * The stored policy record: the create/update body (`id` blank on create) and what * the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId` @@ -150,12 +153,26 @@ export async function fetchTriggers(): Promise { } /** - * POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured - * sources, regardless of the enabled flag. Returns the ids of the runs started - * (empty when the sources yielded no work); poll {@link fetchRun} for each. + * What a manual trigger found and started. Mirrors the backend `SweepOutcome`: + * when `runIds` is empty, the counts say why - no files listed, all already + * processed at their current version, parked by a failed run, or still in + * flight from an earlier sweep. */ -export async function triggerPipeline(id: string): Promise { - return apiClient.local.json( +export interface TriggerOutcome { + runIds: string[]; + filesListed: number; + alreadyProcessed: number; + parked: number; + inFlight: number; +} + +/** + * POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured + * sources, regardless of the enabled flag. Returns the runs started plus what the + * sweep skipped; poll {@link fetchRun} for each run id. + */ +export async function triggerPipeline(id: string): Promise { + return apiClient.local.json( `/api/v1/policies/${encodeURIComponent(id)}/trigger`, { method: "POST" }, ); diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 7954e04c59..5c3c33a75a 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -207,7 +207,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ { id: "retention", label: "portal.policies.categories.retention.label", - icon: "clock", + icon: "schedule", tone: "neutral", desc: "portal.policies.categories.retention.desc", comingSoon: true, @@ -238,15 +238,15 @@ export const POLICY_CONFIG: Record = { label: "portal.policies.config.ingestion.fields.minConfidence", key: "minConfidence", type: "select", - value: "80%", - options: ["60%", "70%", "80%", "90%", "95%"], + value: "p80", + options: ["p60", "p70", "p80", "p90", "p95"], }, { label: "portal.policies.config.ingestion.fields.belowThreshold", key: "belowThreshold", type: "select", - value: "Flag for review", - options: ["Flag for review", "Route to bucket", "Hold"], + value: "flagForReview", + options: ["flagForReview", "routeToBucket", "hold"], }, ], }, @@ -305,19 +305,19 @@ export const POLICY_CONFIG: Record = { label: "portal.policies.config.compliance.fields.frameworks", key: "frameworks", type: "chips", - value: ["HIPAA"], - options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"], + value: ["hipaa"], + options: ["hipaa", "gdpr", "soc2", "fedramp", "pciDss", "iso27001"], }, { label: "portal.policies.config.compliance.fields.onViolation", key: "onViolation", type: "select", - value: "Flag for review", + value: "flagForReview", options: [ - "Flag for review", - "Block export", - "Auto-redact PHI", - "Quarantine document", + "flagForReview", + "blockExport", + "autoRedactPhi", + "quarantineDocument", ], }, { @@ -348,8 +348,8 @@ export const POLICY_CONFIG: Record = { label: "portal.policies.config.routing.fields.destination", key: "destination", type: "select", - value: "Documents", - options: ["Documents", "S3 bucket", "SharePoint", "Webhook"], + value: "documents", + options: ["documents", "s3Bucket", "sharePoint", "webhook"], }, { label: "portal.policies.config.routing.fields.webhookUrl", @@ -379,15 +379,21 @@ export const POLICY_CONFIG: Record = { label: "portal.policies.config.retention.fields.keepFor", key: "keepFor", type: "select", - value: "7 years", - options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"], + value: "sevenYears", + options: [ + "thirtyDays", + "oneYear", + "threeYears", + "sevenYears", + "indefinite", + ], }, { label: "portal.policies.config.retention.fields.archiveAfter", key: "archiveAfter", type: "select", - value: "Never", - options: ["30 days", "90 days", "1 year", "Never"], + value: "never", + options: ["thirtyDays", "ninetyDays", "oneYear", "never"], }, { label: "portal.policies.config.retention.fields.immutableHold", @@ -400,14 +406,14 @@ export const POLICY_CONFIG: Record = { }; export const POLICY_DOC_TYPES: string[] = [ - "Contracts", - "Invoices", - "Tax documents", - "HR records", - "Insurance", - "Medical / PHI", - "Legal filings", - "Financial reports", + "contracts", + "invoices", + "taxDocuments", + "hrRecords", + "insurance", + "medicalPhi", + "legalFilings", + "financialReports", ]; // ── Client-side catalogue assembly ─────────────────────────────────────────── diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 26d73e5814..4d8a28e057 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -73,6 +73,16 @@ export const JOURNEY: JourneyStep[] = [ }, ]; +/** + * The commercial flow's stepper stages. The real backend collapses quote + agreement into one + * accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer + * step than the mock ledger's {@link JOURNEY} — the separate "Agreement" step is dropped. Reuses + * JOURNEY's i18n keys. + */ +export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter( + (s) => s.stage !== "security", +); + /* ──────────────────────────────────────────────────────────────────────── */ /* Deal header */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -284,12 +294,18 @@ export interface QuoteResult { currency: string; annualNetMinor: number; tcvMinor: number; + /** First post-term renewal fee after the CPI escalator; the committed term itself is flat. */ + renewalAnnualNetMinor: number; + /** The fixed CPI escalator applied per renewal, as a whole percent (e.g. 3). */ + cpiRatePct: number; lineItems: QuoteLineItem[]; validUntil: string | null; /** The Stripe Quote id once issued; null while still a local draft. */ stripeQuoteId: string | null; /** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */ invoiceUrl: string | null; + /** Direct PDF link for that invoice; persisted so the download button survives a reload. */ + invoicePdf: string | null; /** The inputs this quote was priced from, so the builder can seed itself on re-edit. */ config: QuoteConfigInput; } @@ -306,6 +322,10 @@ export interface AcceptResult { export interface ProcurementSnapshot { dealId: number | null; stage: DealStage | null; + /** cloud | selfhost | airgap — chosen at the trial-setup step; seeds the quote builder. */ + deployment: string; + /** Seat count captured at trial setup (0 = unspecified); seeds the builder's volume estimate. */ + seats: number; trialStartedAt: string | null; trialEndsAt: string | null; trialExtensionsUsed: number; @@ -318,17 +338,35 @@ export interface ProcurementSnapshot { export interface QuoteConfigInput { volume: number; users: number; + /** Policy posture (governance) as runs per PDF: Essentials 2, Governed 4, Regulated 7. */ + intensity: number; + /** PDF-size tier multiplier on the rate: Compact 1.0, Standard 1.4, Heavy 2.4. */ + sizeMult: number; + /** cloud | selfhost | airgap — set at the trial; drives the flat deployment fee + offline .lic. */ deployment: string; termYears: number; serviceLevel: string; indemnification: boolean; training: boolean; qbr: boolean; - /** Offline / air-gapped licence file — a paid add-on. */ - offlineLicense: boolean; - currency: string; /** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */ businessName: string; + // Buyer / AP details (all optional). They flow onto the Stripe customer (name + bill-to address) + // and the invoice (PO number + tax id as custom fields). Country + currency are out of scope. + /** Signatory / main contact name. */ + contactName?: string; + /** Contact email (billing / signatory). */ + contactEmail?: string; + addressLine1?: string; + addressLine2?: string; + city?: string; + /** State / province / region. */ + region?: string; + postalCode?: string; + /** Purchase-order number, shown on the invoice for AP matching. */ + poNumber?: string; + /** VAT / Tax ID, shown on the invoice. */ + taxId?: string; } export function fetchSnapshot(): Promise { @@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise { return apiClient.saas.text("/api/v1/procurement/license/file"); } -export function startTrial(): Promise { +/** + * Start the trial with the buyer's chosen deployment target and seat count (captured in the setup + * step). These seed the quote builder; both remain editable when the quote is built. + */ +export function startTrial( + deployment: string, + seats: number, +): Promise { return apiClient.saas.json( "/api/v1/procurement/trial/start", - { method: "POST" }, + { method: "POST", body: { deployment, users: seats } }, ); } diff --git a/frontend/editor/src/portal/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts index f5538f47ee..a7d92f0eb5 100644 --- a/frontend/editor/src/portal/auth/editorUrl.ts +++ b/frontend/editor/src/portal/auth/editorUrl.ts @@ -1,17 +1,29 @@ -/** - * Where to send users to reach the editor app (app switcher, and the auth gate - * bouncing non-admins out). - * - * Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than - * hardcoded, falling back to "/" (the editor serves the portal at /processor - * on the same origin, so the root is the editor). For dev cross-app navigation to - * a separately-running editor, set VITE_EDITOR_URL in editor/.env.local. - */ -export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/"; +import { withBasePath } from "@app/constants/app"; + +const CONFIGURED_EDITOR_URL = import.meta.env.VITE_EDITOR_URL || ""; /** * When the editor is at the root of this origin, the portal and editor are * route-sets of one SPA — switching apps can be a client-side navigation - * instead of a full page load. + * instead of a full page load. True unless a distinct VITE_EDITOR_URL points at + * a separately-running editor (dev cross-app setup). */ -export const EDITOR_IS_SAME_APP = EDITOR_URL === "/"; +export const EDITOR_IS_SAME_APP = + !CONFIGURED_EDITOR_URL || CONFIGURED_EDITOR_URL === "/"; + +/** + * Where to send users to reach the editor app (app switcher, the auth gate + * bouncing non-admins out, the "Open in browser" CTAs on the home hero and the + * download-editor modal). + * + * Sourced from VITE_EDITOR_URL so it's configurable per deploy. When unset (the + * editor is this same app at the origin root), it resolves to the deploy's base + * path — so a subpath deploy (RUN_SUBPATH=/app → served under /app) lands on + * /app/ rather than the origin root. These CTAs use window.location, which + * bypasses the router basename, so the base path has to be baked in here. For + * dev cross-app navigation to a separately-running editor, set VITE_EDITOR_URL + * in editor/.env.local. + */ +export const EDITOR_URL = EDITOR_IS_SAME_APP + ? withBasePath("/") + : CONFIGURED_EDITOR_URL; diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index 606f021c0b..b3744d36bf 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -1,19 +1,17 @@ import type { ReactNode } from "react"; import { Sidebar } from "@portal/components/Sidebar"; -import { Header } from "@portal/components/Header"; import "@portal/components/AppShell.css"; /** - * Two-column layout: fixed-width sidebar on the left, sticky header + scrolling - * main column on the right. The Sidebar and Header read their state from - * context, so this shell stays prop-free. + * Two-column layout: fixed-width sidebar on the left, a scrolling main column on + * the right. The Sidebar reads its state from context, so this shell stays + * prop-free. */ export function AppShell({ children }: { children: ReactNode }) { return (
-
{children}
diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.css b/frontend/editor/src/portal/components/DownloadEditorModal.css new file mode 100644 index 0000000000..fba739501f --- /dev/null +++ b/frontend/editor/src/portal/components/DownloadEditorModal.css @@ -0,0 +1,114 @@ +/* ──────────────────────────────────────────────────────────────────────── */ +/* Install-the-editor modal */ +/* ──────────────────────────────────────────────────────────────────────── */ + +.portal-install__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.portal-install__section { + margin: 0.75rem 0 0.125rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-5); +} +.portal-install__section:first-child { + margin-top: 0; +} + +.portal-install__option { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.875rem; + width: 100%; + padding: 0.75rem 0.875rem; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: var(--color-surface); + text-align: left; + cursor: pointer; + transition: + border-color var(--motion-fast), + background var(--motion-fast); +} +.portal-install__option:hover { + border-color: var(--color-border-input); + background: var(--color-bg-hover); +} + +.portal-install__option-icon { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: 0.625rem; + color: var(--color-blue); + background: var(--color-blue-light); +} + +.portal-install__option-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.portal-install__option-text strong { + font-size: 0.9375rem; + font-weight: 600; + color: var(--color-text-1); +} +.portal-install__option-text span { + font-size: 0.8125rem; + color: var(--color-text-4); +} +.portal-install__option-chevron { + color: var(--color-text-5); + flex-shrink: 0; +} + +/* ── Detail pane ── */ +.portal-install__detail { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.875rem; +} +.portal-install__detail .sui-btn { + width: 100%; +} + +.portal-install__eyebrow { + margin: 0; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-5); +} +.portal-install__note { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--color-text-4); +} +.portal-install__guide { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0; + border: none; + background: none; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-blue); + cursor: pointer; +} +.portal-install__guide:hover { + text-decoration: underline; +} diff --git a/frontend/editor/src/portal/components/DownloadEditorModal.tsx b/frontend/editor/src/portal/components/DownloadEditorModal.tsx new file mode 100644 index 0000000000..3a4c9bb29d --- /dev/null +++ b/frontend/editor/src/portal/components/DownloadEditorModal.tsx @@ -0,0 +1,282 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, CodeBlock, Modal, SegmentedControl } from "@app/ui"; +import { EDITOR_URL } from "@portal/auth/editorUrl"; +import { markEditorInstalled } from "@portal/hooks/useEditorInstalled"; +import { DOWNLOAD_URLS } from "@app/constants/downloads"; +import DownloadRounded from "@mui/icons-material/DownloadRounded"; +import DnsRounded from "@mui/icons-material/DnsRounded"; +import LayersRounded from "@mui/icons-material/LayersRounded"; +import TerminalRounded from "@mui/icons-material/TerminalRounded"; +import ChevronRightRounded from "@mui/icons-material/ChevronRightRounded"; +import OpenInNewRounded from "@mui/icons-material/OpenInNewRounded"; +import type { SvgIconComponent } from "@mui/icons-material"; +import "@portal/components/DownloadEditorModal.css"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Install commands + guides (code isn't translated; only labels are) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const WINGET = "winget install StirlingTools.StirlingPDF"; +const BREW = "brew install --cask stirling-pdf"; +const dockerCmd = (tag: string) => + `docker run -d --name stirling-pdf -p 8080:8080 \\\n -v ./stirling-data:/configs \\\n stirlingtools/stirling-pdf:${tag}`; +const HELM = `helm repo add stirling-pdf https://stirling-tools.github.io/Stirling-PDF/ +helm repo update +helm install stirling-pdf stirling-pdf/stirling-pdf-chart \\\n --namespace stirling-pdf --create-namespace`; +const JAR_URL = "https://files.stirlingpdf.com/Stirling-PDF-with-login.jar"; +const JAR = + "java -Xmx2g -jar Stirling-PDF-with-login.jar\n# then open http://localhost:8080"; + +const GUIDES = { + windows: "https://docs.stirlingpdf.com/Installation/Windows%20Installation/", + mac: "https://docs.stirlingpdf.com/Installation/Mac%20Installation/", + linux: DOWNLOAD_URLS.LINUX_DOCS, + docker: "https://docs.stirlingpdf.com/Installation/Docker%20Install", + kubernetes: "https://docs.stirlingpdf.com/Installation/Kubernetes", + manual: DOWNLOAD_URLS.LINUX_DOCS, +} as const; + +type OptionId = keyof typeof GUIDES; +type DockerVariant = "latest" | "latest-fat" | "latest-ultra-lite"; + +const DESKTOP: OptionId[] = ["windows", "mac", "linux"]; +const SELF_HOSTED: OptionId[] = ["docker", "kubernetes", "manual"]; + +const ICONS: Record = { + windows: DownloadRounded, + mac: DownloadRounded, + linux: DownloadRounded, + docker: DnsRounded, + kubernetes: LayersRounded, + manual: TerminalRounded, +}; + +interface Props { + open: boolean; + onClose: () => void; +} + +function openUrl(url: string) { + window.open(url, "_blank", "noopener,noreferrer"); +} + +/** + * Install-the-editor modal. Lists desktop + self-hosted options; each opens a + * detail pane with a download button and/or copyable install command and a + * guide link. A completing action — clicking a download button or pressing Done + * — marks the getting-started "Download the editor" step complete (via + * {@link markEditorInstalled}); nothing else is persisted. + */ +export function DownloadEditorModal({ open, onClose }: Props) { + const { t } = useTranslation(); + const [selected, setSelected] = useState(null); + const [dockerVariant, setDockerVariant] = useState("latest"); + + const close = () => { + setSelected(null); + onClose(); + }; + + // A download or a Done press completes the "Download the editor" step. + const download = (url: string) => { + openUrl(url); + markEditorInstalled(); + }; + const done = () => { + markEditorInstalled(); + close(); + }; + + const guideLink = (id: keyof typeof GUIDES) => ( + + ); + + const note = (id: OptionId) => ( +

+ {t(`portal.home.download.${id}.note`)} +

+ ); + + function renderDetail(id: OptionId) { + switch (id) { + case "windows": + case "mac": { + const url = + id === "windows" ? DOWNLOAD_URLS.WINDOWS : DOWNLOAD_URLS.MAC; + return ( + <> + +

+ {t(`portal.home.download.${id}.altLabel`)} +

+ + {guideLink(id)} + + ); + } + case "linux": + return ( + <> + {note("linux")} + + + ); + case "docker": + return ( + <> + + value={dockerVariant} + onChange={setDockerVariant} + options={[ + { + label: t("portal.home.download.docker.variantStandard"), + value: "latest", + }, + { + label: t("portal.home.download.docker.variantFat"), + value: "latest-fat", + }, + { + label: t("portal.home.download.docker.variantLite"), + value: "latest-ultra-lite", + }, + ]} + /> + + {note("docker")} + {guideLink("docker")} + + ); + case "kubernetes": + return ( + <> + + {note("kubernetes")} + {guideLink("kubernetes")} + + ); + case "manual": + return ( + <> + +

+ {t("portal.home.download.manual.runLabel")} +

+ + {note("manual")} + {guideLink("manual")} + + ); + } + } + + const renderRow = (id: OptionId) => { + const Icon = ICONS[id]; + return ( + + ); + }; + + const detail = selected !== null; + + return ( + + + +
+ ) : ( + + ) + } + > + {detail ? ( +
{renderDetail(selected)}
+ ) : ( +
+

+ {t("portal.home.download.sectionDesktop")} +

+ {DESKTOP.map(renderRow)} +

+ {t("portal.home.download.sectionSelfHosted")} +

+ {SELF_HOSTED.map(renderRow)} +
+ )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 313d283d6a..83426bae91 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -14,7 +14,8 @@ display: flex; align-items: center; gap: 1.25rem; - padding: 1.25rem 1.5rem; + padding: 1rem 1.25rem; + background: #16213e; } .portal-editor-hero__logo { @@ -51,7 +52,7 @@ .portal-editor-hero__name { font-size: 1.125rem; font-weight: 700; - color: var(--color-text-1); + color: #fff; margin-right: 0.125rem; } @@ -59,27 +60,21 @@ display: inline-flex; align-items: center; gap: 0.3125rem; - padding: 0.3125rem 0.75rem; + padding: 0.25rem 0.6875rem; border-radius: var(--radius-pill); - border: 1px solid var(--color-border-input); - background: var(--color-bg-code); - color: var(--color-text-2); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.85); font-size: 0.71875rem; font-weight: 600; cursor: pointer; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); - transition: - background var(--motion-fast), - box-shadow var(--motion-fast), - transform var(--motion-fast); + transition: background var(--motion-fast); } .portal-editor-hero__chip:hover { - background: var(--color-surface); - box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1); - transform: translateY(-1px); + background: rgba(255, 255, 255, 0.16); } .portal-editor-hero__chip svg { - color: var(--color-text-4); + color: rgba(255, 255, 255, 0.6); } .portal-editor-hero__meta { @@ -88,19 +83,51 @@ gap: 0.5rem; flex-wrap: wrap; font-size: 0.75rem; - color: var(--color-text-5); + color: rgba(255, 255, 255, 0.5); } .portal-editor-hero__host { - color: var(--color-text-2); + color: rgba(255, 255, 255, 0.92); font-family: var(--font-mono); font-weight: 500; } .portal-editor-hero__meta-sep { - color: var(--color-border-input); + color: rgba(255, 255, 255, 0.3); } .portal-editor-hero__action { flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Icon-only actions (e.g. install) on the dark header. */ +.portal-editor-hero__icon-btn { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: #fff; + cursor: pointer; + transition: background var(--motion-fast); +} +.portal-editor-hero__icon-btn:hover { + background: rgba(255, 255, 255, 0.16); +} + +/* White CTA on the dark header, matching the marketing card. */ +.portal-editor-hero__action .portal-editor-hero__cta.sui-btn { + background: #ffffff; + border-color: #ffffff; + color: #16213e; +} +.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(255, 255, 255, 0.88); } @media (max-width: 48rem) { diff --git a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx index 84126547eb..9e73983026 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx @@ -2,6 +2,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { http, HttpResponse } from "msw"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const progress: OnboardingProgress = { + loading: false, + deployed: true, + editorDone: true, + policiesDone: true, + inviteDone: false, + policiesActive: 3, + policiesRecommended: 4, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/EditorStatusCard", @@ -25,7 +37,7 @@ export const Default: Story = {}; /** As it renders on the subscribed home: the setup checklist attached as the footer. */ export const WithSetupChecklist: Story = { args: { - footer: , + footer: , }, }; @@ -36,7 +48,7 @@ export const WithSetupChecklist: Story = { */ export const DeploymentUnavailable: Story = { args: { - footer: , + footer: , }, parameters: { msw: { diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 9c97041260..8d3b7dcbcf 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { Fragment, useMemo } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; @@ -11,10 +11,12 @@ import { type EditorInstance, } from "@portal/api/editorDeploy"; import { + DownloadIcon, ExternalLinkIcon, UsersIcon, UserPlusIcon, } from "@portal/components/icons"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/components/EditorStatusCard.css"; /** The Stirling brand mark, drawn at the hero size. Decorative. */ @@ -73,6 +75,7 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); + const [installOpen, setInstallOpen] = useState(false); const { data, loading } = useAsync( () => fetchEditorDeployment(tier), [tier], @@ -138,26 +141,16 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { {t("portal.home.editor.name")} {!hideChips && ( - <> - - - + )}
@@ -174,8 +167,29 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
+ {!hideChips && ( + + )} +
{footer &&
{footer}
} + + setInstallOpen(false)} + /> ); } diff --git a/frontend/editor/src/portal/components/Header.css b/frontend/editor/src/portal/components/Header.css deleted file mode 100644 index 047009d2f3..0000000000 --- a/frontend/editor/src/portal/components/Header.css +++ /dev/null @@ -1,182 +0,0 @@ -.portal-header { - height: 3.25rem; - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0 1rem; - background: var(--color-header-bg); - border-bottom: 1px solid var(--color-header-border); - position: sticky; - top: 0; - z-index: 10; -} - -.portal-header__left { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 0.5rem; - min-width: 12.5rem; -} - -.portal-header__breadcrumb { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-header-text); -} - -/* Search trigger */ -.portal-header__search { - flex: 0 1 20rem; - margin: 0 auto; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.625rem; - background: var(--color-search-bg); - border: 1px solid var(--color-search-border); - border-radius: var(--radius-md); - color: var(--color-search-text); - font-size: 0.75rem; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} -.portal-header__search:hover { - border-color: var(--color-search-border-hover); -} - -.portal-header__search-placeholder { - flex: 1 1 auto; - text-align: left; -} - -.portal-header__search-kbd { - font-family: var(--font-mono); - font-size: 0.6875rem; - padding: 0.0625rem 0.25rem; - border-radius: var(--radius-xs); - background: var(--color-bg-muted); - color: var(--color-text-4); -} - -/* Right cluster */ -.portal-header__right { - flex: 0 0 auto; - display: flex; - align-items: center; - gap: 0.375rem; -} - -.portal-header__icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: var(--radius-md); - color: var(--color-bell-stroke, var(--color-text-3)); - position: relative; - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.portal-header__icon-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} - -.portal-header__bell-dot { - position: absolute; - top: 0.375rem; - right: 0.4375rem; - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: var(--color-red); - border: 2px solid var(--color-header-bg); - box-sizing: content-box; -} - -/* Tier switcher */ -.portal-header__tier { - position: relative; -} - -.portal-header__tier-btn { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.3125rem 0.625rem; - font-size: 0.75rem; - color: var(--color-text-2); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} -.portal-header__tier-btn:hover { - background: var(--color-bg-hover); - border-color: var(--color-border-hover); -} - -.portal-header__tier-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; -} - -.portal-header__tier-label { - font-weight: 500; -} - -.portal-header__tier-menu { - position: absolute; - top: calc(100% + 0.25rem); - right: 0; - min-width: 12rem; - margin: 0; - padding: 0.25rem; - list-style: none; - background: var(--color-dropdown-bg); - border: 1px solid var(--color-dropdown-border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - z-index: 20; - animation: fadeInUp var(--motion-enter) both; -} - -.portal-header__tier-option { - width: 100%; - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4375rem 0.625rem; - font-size: 0.8125rem; - color: var(--color-text-2); - border-radius: var(--radius-sm); - text-align: left; -} -.portal-header__tier-option:hover { - background: var(--color-dropdown-hover); -} -.portal-header__tier-option.is-active { - background: var(--color-nav-active); - color: var(--color-nav-active-text); -} - -/* Avatar */ -.portal-header__avatar { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: 50%; - background: var(--grad-blue-btn); - color: #fff; - font-size: 0.8125rem; - font-weight: 600; -} diff --git a/frontend/editor/src/portal/components/Header.stories.tsx b/frontend/editor/src/portal/components/Header.stories.tsx deleted file mode 100644 index 25f2b9c2b5..0000000000 --- a/frontend/editor/src/portal/components/Header.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Header } from "@portal/components/Header"; - -const meta: Meta = { - title: "Portal/Shell/Header", - component: Header, - parameters: { layout: "fullscreen" }, - decorators: [ - (S) => ( -
- -
- Page body would render below the header. -
-
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const FreeTier: Story = { globals: { tier: "free" } }; -export const ProTier: Story = { globals: { tier: "pro" } }; -export const EnterpriseTier: Story = { globals: { tier: "enterprise" } }; diff --git a/frontend/editor/src/portal/components/Header.tsx b/frontend/editor/src/portal/components/Header.tsx deleted file mode 100644 index 76a3e5e45d..0000000000 --- a/frontend/editor/src/portal/components/Header.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ActionIcon, Avatar, Button, Dropdown } from "@app/ui"; -import { useAuth } from "@app/auth"; -import { useTheme } from "@portal/contexts/ThemeContext"; -import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import { useUI } from "@portal/contexts/UIContext"; -import { - SearchIcon, - SunIcon, - MoonIcon, - ChevronDownIcon, -} from "@portal/components/icons"; -import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; -import "@portal/components/Header.css"; - -function ThemeToggle() { - const { theme, toggle } = useTheme(); - const { t } = useTranslation(); - return ( - - {theme === "light" ? : } - - ); -} - -function TierSwitcher() { - const { tier, setTier, isDerived } = useTier(); - const info = TIER_INFO[tier]; - // In the app the tier is derived from the real link/wallet state and can't - // be switched by hand; the dropdown only renders where the tier is pinned - // (Storybook / demo surfaces — see TierProvider's initialTier). - if (isDerived) return null; - return ( - - - - - - {(Object.keys(TIER_INFO) as Tier[]).map((id) => ( - setTier(id)} - leading={ - - } - > - {TIER_INFO[id].label} - - ))} - - - ); -} - -function UserMenu() { - const { t } = useTranslation(); - const { displayName, signOut } = useAuth(); - const name = - displayName ?? t("portal.shell.header.accountFallback", "Account"); - return ( - - - - - - - - {name} - void signOut()}> - {t("portal.shell.header.signOut", "Sign out")} - - - - ); -} - -export function Header() { - const { activeView } = useView(); - const { openSearch } = useUI(); - const { t } = useTranslation(); - return ( -
-
- - {t(`portal.nav.${activeView}`)} - -
- - - -
- - - - -
-
- ); -} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 643703f447..03bcc17ed0 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -1,40 +1,60 @@ import type { Tier } from "@portal/contexts/TierContext"; +import { useUI } from "@portal/contexts/UIContext"; import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; /** - * The Home hero, composed per tier with a procurement-aware footer: + * The Home hero, composed with a procurement-aware, progress-aware footer: * - * - free → marketing welcome banner - * - subscribed → deployed-Editor status card - * - enterprise → deployed-Editor status card (chips hidden; the deal hero owns invite) + * - no live deployment → welcome header (+ setup steps until complete) + * - deployment live → deployed-Editor status header (+ steps until complete) + * - onboarding complete → header only; the setup steps collapse away + * - enterprise → status header with chips hidden (the deal hero owns invite) * * The footer is the deal-status hero while a procurement deal is underway - * (procurement is a bolt-on to any tier), otherwise the setup checklist. The - * procurement takeover flow modals render alongside so the hero's actions can - * open them. + * (procurement is a bolt-on to any tier); otherwise the setup checklist, until + * every step is done — then it collapses to just the header, matching the + * deployed-status card. The procurement takeover modals render alongside. */ export function HomeHero({ tier }: { tier: Tier }) { + const { openLinkModal } = useUI(); const procurement = useProcurement(); + const progress = useOnboardingProgress(); const dealActive = procurement.isLinked && procurement.started && !!procurement.data; + // Start the enterprise flow right here on Home: open the trial-setup modal when the account is + // linked, otherwise prompt to link first — no navigating off to the procurement view. + const onStartEnterprise = () => { + if (procurement.isLinked) procurement.onStartTrial(); + else openLinkModal(); + }; + + // Steps collapse once onboarding is complete; a live deal always keeps its + // hero. Otherwise the setup checklist carries the (progress-aware) steps. const footer = dealActive ? ( - ) : ( - + ) : progress.allComplete ? undefined : ( + ); + // The live-status header (EditorStatusCard) needs a real deployment to show; + // without one it renders nothing, so route to it only when actually deployed. + // Everything else — including a step completed via the local download flag — + // keeps the always-present welcome header, so the card never vanishes. + const showStatus = progress.deployed; + return ( <> - {tier === "free" ? ( - - ) : ( + {showStatus ? ( + ) : ( + )} diff --git a/frontend/editor/src/portal/components/LoginScreen.tsx b/frontend/editor/src/portal/components/LoginScreen.tsx index 7abf518a0c..f16553d56d 100644 --- a/frontend/editor/src/portal/components/LoginScreen.tsx +++ b/frontend/editor/src/portal/components/LoginScreen.tsx @@ -1,19 +1,23 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { AuthShell } from "@app/auth/ui/AuthShell"; import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel"; import { buildDefaultLoginSlides } from "@app/auth/ui/loginSlides"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@app/auth/ui/useSpringLogin"; +import { withBasePath } from "@app/constants/app"; import "@app/auth/ui/auth-theme.css"; import "@app/auth/ui/auth.css"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; /** - * Full-screen login shown by the portal's auth gate. Renders the same screen as - * the editor: the shared AuthShell + carousel, with the form body and Spring - * auth wiring from @app/auth/ui (SpringLoginForm + useSpringLogin). The gate - * handles "already logged in", so this only needs to collect credentials. + * Full-screen login shown by the portal's auth gate. Renders the shared + * AuthShell + carousel with the Spring form/auth wiring from @app/auth/ui. + * + * It follows the user's light/dark theme (AuthShell is theme-aware — the same + * screen the editor login uses; passing both logo variants keeps the header + * readable in either mode). The gate handles "already logged in", so this only + * needs to collect credentials. */ export function LoginScreen() { const { t } = useTranslation(); @@ -23,16 +27,6 @@ export function LoginScreen() { [t], ); - // Auth pages render in light mode (the shared screen uses light-only tokens). - useEffect(() => { - const html = document.documentElement; - const previous = html.getAttribute("data-mantine-color-scheme"); - html.setAttribute("data-mantine-color-scheme", "light"); - return () => { - if (previous) html.setAttribute("data-mantine-color-scheme", previous); - }; - }, []); - return ( } > - + ); } diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx deleted file mode 100644 index 1470f5e447..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { NotificationsDropdown } from "@portal/components/NotificationsDropdown"; -import { NOTIFICATIONS } from "@portal/mocks/notifications"; - -const meta: Meta = { - title: "Portal/Header/NotificationsDropdown", - component: NotificationsDropdown, - parameters: { layout: "centered" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const Empty: Story = { - parameters: { - msw: { - handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))], - }, - }, -}; - -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", async () => { - await delay("infinite"); - return HttpResponse.json([]); - }), - ], - }, - }, -}; - -export const HighVolume: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", () => { - const items = Array.from({ length: 24 }, (_, i) => ({ - ...NOTIFICATIONS[i % NOTIFICATIONS.length], - id: `n${i + 1}`, - })); - return HttpResponse.json(items); - }), - ], - }, - }, -}; - -export const NetworkError: Story = { - parameters: { - msw: { - handlers: [ - http.get("/v1/notifications", () => - HttpResponse.json({ error: "Service unavailable" }, { status: 503 }), - ), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.tsx deleted file mode 100644 index 1401837e53..0000000000 --- a/frontend/editor/src/portal/components/NotificationsDropdown.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { ActionIcon, Button, Dropdown, EmptyState, Skeleton } from "@app/ui"; -import { BellIcon } from "@portal/components/icons"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchNotifications, - markAllNotificationsRead, - type Notification, - type NotificationCategory, -} from "@portal/api/notifications"; -import "@portal/components/NotificationsDropdown.css"; - -const CATEGORY_COLOUR: Record = { - pipeline: "var(--color-blue)", - deploy: "var(--color-green)", - billing: "var(--color-amber)", - audit: "var(--color-purple)", - agent: "var(--color-cat-insurance)", - doc: "var(--color-cat-healthcare)", -}; - -export function NotificationsDropdown() { - const { t } = useTranslation(); - const state = useAsync(() => fetchNotifications(), []); - const { data: items } = state; - const { isLoading } = useSectionFlags(state); - - // Optimistically clear on "mark all read"; revert if the request fails. - const [cleared, setCleared] = useState(false); - const visible = cleared ? [] : (items ?? []); - - async function onMarkAllRead() { - setCleared(true); - try { - await markAllNotificationsRead(); - } catch { - setCleared(false); - } - } - - const isEmpty = !isLoading && visible.length === 0; - const hasUnread = visible.length > 0; - - return ( - - - - - {hasUnread && ( - - )} - - - -
- - {t("portal.notifications.title")} - - {hasUnread ? ( - - {t("portal.notifications.count.new", { count: visible.length })} - - ) : isLoading ? ( - - {t("portal.notifications.count.loading")} - - ) : ( - - {t("portal.notifications.count.allRead")} - - )} -
- {isLoading && ( -
- - - - -
- )} - {isEmpty && ( - - )} - {!isLoading && !isEmpty && ( -
    - {visible.map((item) => ( -
  • - -
    -
    {item.title}
    -
    {item.description}
    -
    {item.time}
    -
    -
  • - ))} -
- )} -
- - -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx index 29d93cda3c..cffb822387 100644 --- a/frontend/editor/src/portal/components/PolicySummary.tsx +++ b/frontend/editor/src/portal/components/PolicySummary.tsx @@ -67,8 +67,8 @@ export function PolicySummary() { {policyIcon(entry.category.icon)}
- {entry.category.label} - {entry.category.desc} + {t(entry.category.label)} + {t(entry.category.desc)}
), @@ -92,7 +92,7 @@ export function PolicySummary() { render: ({ entry, state }) => ( {state === "active" - ? entry.config.summary + ? t(entry.config.summary) : t("portal.policySummary.noRule")} ), diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index 49172784fc..1262c84b8e 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -4,7 +4,6 @@ import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { ErrorBoundary } from "@portal/components/ErrorBoundary"; import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; -import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; @@ -66,7 +65,6 @@ export function PortalChrome() { - diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css index b49744774e..a19919e640 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ b/frontend/editor/src/portal/components/SetupChecklist.css @@ -1,5 +1,5 @@ /* ──────────────────────────────────────────────────────────────────────── */ -/* "Finish setting up" checklist (welcome-hero footer) */ +/* Getting-started steps (home-hero body) */ /* ──────────────────────────────────────────────────────────────────────── */ .portal-setup { @@ -7,43 +7,6 @@ flex-direction: column; } -.portal-setup__head { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.6875rem 1.25rem 0.1875rem; -} - -.portal-setup__eyebrow { - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-text-5); -} - -/* Progress counter doubles as the dismiss affordance. */ -.portal-setup__dismiss { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.1875rem 0.4375rem; - border: none; - border-radius: var(--radius-md); - background: transparent; - font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-5); - cursor: pointer; - transition: - color var(--motion-fast), - background var(--motion-fast); -} -.portal-setup__dismiss:hover { - color: var(--color-text-2); - background: var(--color-bg-hover); -} - .portal-setup__list { list-style: none; margin: 0; @@ -52,39 +15,46 @@ .portal-setup__row { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: auto 1fr; align-items: center; gap: 0.875rem; width: 100%; - padding: 0.875rem 1.25rem; + padding: 0.6875rem 1.25rem; border: none; border-top: 1px solid var(--color-border-light); background: transparent; text-align: left; + cursor: pointer; transition: background var(--motion-fast); } .portal-setup__item:first-child .portal-setup__row { border-top: none; } -.portal-setup__row.is-interactive { - cursor: pointer; -} -.portal-setup__row.is-interactive:hover { +.portal-setup__row:hover { background: var(--color-bg-hover); } -.portal-setup__row:disabled { - cursor: default; -} -.portal-setup__dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; +/* Numbered step marker */ +.portal-setup__num { + display: grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; flex-shrink: 0; - background: var(--color-border-input); + border-radius: 50%; + border: 1px solid var(--color-border-input); + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-4); } -.portal-setup__dot.is-done { +/* Completed step: filled green check. */ +.portal-setup__num.is-done { + border-color: var(--color-green); background: var(--color-green); + color: #fff; +} +.portal-setup__row.is-done .portal-setup__text strong { + color: var(--color-text-2); } .portal-setup__text { @@ -93,52 +63,16 @@ min-width: 0; } .portal-setup__text strong { - font-size: 0.84375rem; + font-size: 0.875rem; font-weight: 600; color: var(--color-text-1); } .portal-setup__text span { - margin-top: 0.0625rem; font-size: 0.75rem; line-height: 1.4; color: var(--color-text-4); } -.portal-setup__chip { - flex-shrink: 0; - padding: 0.125rem 0.5625rem; - border-radius: var(--radius-pill); - font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-5); - background: var(--color-bg-hover); -} -.portal-setup__chip.is-done { - color: var(--color-green-dark); - background: var(--color-green-light); -} - -/* Loading skeleton */ -.portal-setup__row.is-loading { - border-top: 1px solid var(--color-border-light); -} -.portal-setup__skel { - height: 0.75rem; - width: 60%; - border-radius: var(--radius-sm); - background: var(--color-bg-muted); - animation: portalSetupPulse 1.2s ease-in-out infinite; -} -@keyframes portalSetupPulse { - 0%, - 100% { - opacity: 0.5; - } - 50% { - opacity: 1; - } -} - /* ── Enterprise upsell rung ── */ .portal-setup__enterprise { display: flex; @@ -146,7 +80,7 @@ justify-content: space-between; gap: 1rem; flex-wrap: wrap; - padding: 0.875rem 1.25rem; + padding: 0.75rem 1.25rem; border-top: 1px solid var(--color-border-light); background: linear-gradient( 90deg, diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx index ba8014921d..78af75d188 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx @@ -1,11 +1,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const base: OnboardingProgress = { + loading: false, + deployed: false, + editorDone: false, + policiesDone: false, + inviteDone: false, + policiesActive: 0, + policiesRecommended: 6, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/SetupChecklist", component: SetupChecklist, parameters: { layout: "padded" }, + args: { progress: base }, decorators: [ (S) => (
= { export default meta; type Story = StoryObj; -/** Completion + counts derived from the live policies / sources MSW fixtures. */ -export const Default: Story = {}; +/** A fresh workspace — no step complete yet. */ +export const NotStarted: Story = {}; -/** Slow policy/source fetch: shows the loading skeleton rows. */ -export const Loading: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", async () => { - await delay(100000); - return HttpResponse.json([]); - }), - ], +/** Policies confirmed; editor + invite still open. */ +export const InProgress: Story = { + args: { + progress: { + ...base, + policiesDone: true, + policiesActive: 2, + policiesRecommended: 5, }, }, }; -/** A fresh workspace — no active policies or connected sources: every step reads "Not started". */ -export const NotStarted: Story = { - parameters: { - msw: { - handlers: [ - http.get("/api/v1/policies", () => HttpResponse.json([])), - http.get("/api/v1/policies/runs", () => HttpResponse.json([])), - http.get("/api/v1/sources", () => - HttpResponse.json({ kpis: [], sources: [] }), - ), - ], +/** Editor deployed + policies on; only the invite step remains. */ +export const AlmostDone: Story = { + args: { + progress: { + ...base, + editorDone: true, + policiesDone: true, + policiesActive: 4, + policiesRecommended: 3, }, }, }; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx index bc41c30dd6..d854beb771 100644 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ b/frontend/editor/src/portal/components/SetupChecklist.tsx @@ -1,27 +1,30 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchPolicies, type PoliciesResponse } from "@portal/api/policies"; -import { fetchSources, type SourcesResponse } from "@portal/api/sources"; -import { CloseIcon } from "@portal/components/icons"; +import { useView } from "@portal/contexts/ViewContext"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; +import CheckRounded from "@mui/icons-material/CheckRounded"; import "@portal/components/SetupChecklist.css"; -/** Where the Stirling PDF Editor desktop app is downloaded. */ -const EDITOR_DOWNLOAD_URL = "https://stirling.com/download"; - /* ──────────────────────────────────────────────────────────────────────── */ /* Enterprise upsell rung */ /* ──────────────────────────────────────────────────────────────────────── */ /** * Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided - * trial, subscribed (paying) orgs jump straight to a quote — both land in the - * procurement flow. + * trial, subscribed (paying) orgs jump straight to a quote — both open the + * procurement flow. When {@code onStart} is given the CTA opens the flow's setup + * modal over Home; otherwise it falls back to navigating to the procurement view. */ -function EnterpriseRung({ paying }: { paying: boolean }) { +function EnterpriseRung({ + paying, + onStart, +}: { + paying: boolean; + onStart?: () => void; +}) { const { t } = useTranslation(); const { setActiveView } = useView(); return ( @@ -38,7 +41,7 @@ function EnterpriseRung({ paying }: { paying: boolean }) { -
+ + ))} + -
    - {steps.map((s) => ( -
  1. - -
  2. - ))} -
- - )} + - + setDownloadOpen(false)} + /> ); } diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index 62d6226151..98a9b63ec3 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -20,24 +20,13 @@ border-bottom: 1px solid var(--color-sidebar-divider); } -/* Stirling brand mark (theme-switched in Sidebar.tsx) + product wordmark */ -.portal-sidebar__brand { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} -.portal-sidebar__brand-mark { +/* Stirling Processor wordmark (theme-switched in Sidebar.tsx); matches the + editor's 22px wordmark so the two apps read as one brand. */ +.portal-sidebar__wordmark { height: 1.375rem; width: auto; display: block; -} -.portal-sidebar__logo-suffix { - font-family: var(--font-brand); - font-size: 1.0625rem; - font-weight: 700; - color: var(--color-text-1); - letter-spacing: 0.01em; - white-space: nowrap; + flex-shrink: 0; } /* App switcher (down-arrow → Portal / Editor); button and menu styling live @@ -77,37 +66,3 @@ flex-direction: column; gap: 0.5rem; } - -.portal-sidebar__usage { - padding: 0.5rem 0.625rem; - font-size: 0.75rem; - color: var(--color-usage-text); -} - -.portal-sidebar__usage-line { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-sidebar__usage-value { - color: var(--color-usage-value); - font-weight: 500; -} - -.portal-sidebar__plan { - display: inline-flex; - align-items: center; - gap: 0.375rem; - color: var(--color-text-2); - font-weight: 500; -} - -.portal-sidebar__plan-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: var(--color-green); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent); -} diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 9bf7c8c373..6e21d8b8d2 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -3,15 +3,12 @@ import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; -import { useAsync } from "@portal/hooks/useAsync"; -import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; -import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; +import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; import { SettingsIcon } from "@portal/components/icons"; import { GROUP_PRIMARY, @@ -21,36 +18,6 @@ import { } from "@portal/components/sidebarGroups"; import "@portal/components/Sidebar.css"; -function UsageFooter() { - const { tier } = useTier(); - const { t } = useTranslation(); - // Real 30-day processed-PDF count from the fleet-usage endpoint (the same - // source as Home's status strip, so the two can't drift). null → "—". - const { data, loading } = useAsync(() => fetchFleetStats(), []); - const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined); - - const planLabel = - tier === "free" - ? t("portal.shell.sidebar.planEditor", "Editor plan") - : tier === "pro" - ? t("portal.shell.sidebar.planProcessor", "Processor plan") - : t("portal.shell.sidebar.planEnterprise", "Enterprise plan"); - - return ( -
-
- - - {planLabel} - - - {docs != null ? t("portal.shell.sidebar.docsCount", { docs }) : "—"} - -
-
- ); -} - export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); @@ -94,16 +61,11 @@ export function Sidebar() { aria-label={t("portal.shell.sidebar.primaryNav")} >
- - Stirling - - {t("portal.shell.sidebar.brandSuffix")} - - + {t("portal.shell.sidebar.brandSuffix")} } onClick={() => openSettings()} /> -
); diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 87d284495f..5c63462754 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -1,461 +1,103 @@ /* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero */ +/* Free-tier welcome hero — compact product header + steps */ /* ──────────────────────────────────────────────────────────────────────── */ .portal-welcome { border-radius: var(--radius-xl); - border: 1px solid var(--color-blue-border); + border: 1px solid var(--color-border-light); overflow: hidden; isolation: isolate; -} - -.portal-welcome__body { - position: relative; - display: flex; - align-items: stretch; - min-height: 22.5rem; - background: linear-gradient(135deg, #eaf2fb 0%, #eef3fb 50%, #f4f0fa 100%); -} - -[data-theme="dark"] .portal-welcome__body { - background: linear-gradient(135deg, #0e0e10 0%, #121214 50%, #161618 100%); -} - -/* ── Flowing gradient canvas: three radial blobs drifting at different - speeds for a slow, organic color flow. Purely decorative. ── */ -.portal-welcome__canvas { - position: absolute; - inset: -40%; - width: 180%; - height: 180%; - filter: blur(3.75rem); - pointer-events: none; - z-index: 0; -} - -.portal-welcome__blob { - position: absolute; - border-radius: 50%; -} - -.portal-welcome__blob--1 { - width: 60%; - height: 70%; - top: -10%; - left: 30%; - background: radial-gradient( - circle, - rgba(59, 130, 246, 0.35) 0%, - rgba(99, 102, 241, 0.2) 40%, - transparent 70% - ); - animation: portalHeroBlob1 5s ease-in-out infinite; -} - -.portal-welcome__blob--2 { - width: 55%; - height: 60%; - top: 10%; - right: -15%; - background: radial-gradient( - circle, - rgba(139, 92, 246, 0.4) 0%, - rgba(192, 132, 252, 0.2) 40%, - transparent 70% - ); - animation: portalHeroBlob2 7s ease-in-out infinite; -} - -.portal-welcome__blob--3 { - width: 50%; - height: 55%; - bottom: -15%; - left: 35%; - background: radial-gradient( - circle, - rgba(59, 130, 246, 0.25) 0%, - rgba(14, 165, 233, 0.15) 40%, - transparent 70% - ); - animation: portalHeroBlob3 9s ease-in-out infinite; -} - -[data-theme="dark"] .portal-welcome__blob--1 { - left: 20%; - background: radial-gradient( - circle, - rgba(96, 165, 250, 0.4) 0%, - rgba(99, 102, 241, 0.25) 40%, - transparent 70% - ); -} - -[data-theme="dark"] .portal-welcome__blob--2 { - background: radial-gradient( - circle, - rgba(167, 139, 250, 0.45) 0%, - rgba(192, 132, 252, 0.25) 40%, - transparent 70% - ); -} - -[data-theme="dark"] .portal-welcome__blob--3 { - left: 20%; - background: radial-gradient( - circle, - rgba(96, 165, 250, 0.3) 0%, - rgba(56, 189, 248, 0.18) 40%, - transparent 70% - ); -} - -@keyframes portalHeroBlob1 { - 0% { - transform: translate(0%, 0%) scale(1); - } - 33% { - transform: translate(18%, -25%) scale(1.15); - } - 66% { - transform: translate(-10%, 18%) scale(0.9); - } - 100% { - transform: translate(0%, 0%) scale(1); - } -} - -@keyframes portalHeroBlob2 { - 0% { - transform: translate(0%, 0%) scale(1); - } - 33% { - transform: translate(-20%, 15%) scale(1.1); - } - 66% { - transform: translate(15%, -20%) scale(1.15); - } - 100% { - transform: translate(0%, 0%) scale(1); - } -} - -@keyframes portalHeroBlob3 { - 0% { - transform: translate(0%, 0%) scale(1.05); - } - 33% { - transform: translate(22%, 10%) scale(0.9); - } - 66% { - transform: translate(-15%, -15%) scale(1.15); - } - 100% { - transform: translate(0%, 0%) scale(1.05); - } -} - -@media (prefers-reduced-motion: reduce) { - .portal-welcome__blob { - animation: none; - } -} - -/* ── Left column: copy + CTAs ── */ -.portal-welcome__text { - position: relative; - z-index: 1; - flex: 1 1 0; - min-width: 0; - display: flex; - flex-direction: column; - justify-content: center; - padding: 2.75rem 0 2.75rem 3rem; -} - -.portal-welcome__badge { - align-self: flex-start; - margin-bottom: 1rem; - padding: 0.3125rem 0.875rem; - border-radius: var(--radius-pill); - border: 1px solid var(--color-blue-border); background: var(--color-surface); - font-size: 0.6875rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--color-blue); } -.portal-welcome__title { - margin: 0 0 1.125rem; - font-size: 2.25rem; - font-weight: 700; - line-height: 1.2; - letter-spacing: -0.025em; - color: var(--color-text-1); -} - -.portal-welcome__title-accent { - color: var(--color-blue); -} - -.portal-welcome__sub { - margin: 0 0 1.75rem; - max-width: 32.5rem; - font-size: 1rem; - line-height: 1.6; - color: var(--color-text-3); -} - -.portal-welcome__cta { +/* ── Dark product header strip ── */ +.portal-welcome__header { display: flex; - gap: 0.75rem; align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding: 0.875rem 1.25rem; + background: #16213e; +} + +.portal-welcome__brand { + display: flex; + align-items: center; + gap: 0.875rem; + min-width: 0; +} + +.portal-welcome__mark { + display: grid; + place-items: center; + flex-shrink: 0; +} +.portal-welcome__mark img { + display: block; + height: 1.75rem; + width: auto; +} + +.portal-welcome__brand-text { + display: flex; + align-items: baseline; + gap: 0.625rem; + min-width: 0; flex-wrap: wrap; } -.portal-welcome__perks { - margin: 1.375rem 0 0; - font-size: 0.78125rem; - font-weight: 600; - letter-spacing: 0.01em; - color: var(--color-text-4); +.portal-welcome__product { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; } -/* ── Right column: editor-card visual ── */ -.portal-welcome__visual { - position: relative; - z-index: 1; - display: flex; - align-items: flex-end; - justify-content: flex-end; - flex: none; - padding-left: 1.5rem; -} - -@media (max-width: 60rem) { - .portal-welcome__visual { - display: none; - } - .portal-welcome__text { - padding: 2.5rem 2rem; - } -} - -/* ── Dark editor card ── */ -.portal-welcome__editor { - align-self: flex-end; - width: 38.75rem; - max-width: 100%; - margin-right: 2.5rem; - min-height: 18.75rem; - display: flex; - flex-direction: column; - overflow: hidden; - border-radius: 0.75rem 0.75rem 0 0; - border: 1px solid rgba(255, 255, 255, 0.08); - border-bottom: none; - background: rgba(15, 23, 42, 0.92); - backdrop-filter: blur(0.75rem); - box-shadow: - 0 0.5rem 2rem rgba(0, 0, 0, 0.25), - 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15); -} - -.portal-welcome__editor-bar { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - flex-shrink: 0; -} - -.portal-welcome__editor-dots { - display: flex; - gap: 0.375rem; - flex-shrink: 0; -} -.portal-welcome__editor-dots span { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; -} -.portal-welcome__editor-dots span:nth-child(1) { - background: #ef4444; -} -.portal-welcome__editor-dots span:nth-child(2) { - background: #f59e0b; -} -.portal-welcome__editor-dots span:nth-child(3) { - background: #22c55e; -} - -.portal-welcome__editor-file { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; +.portal-welcome__stats { min-width: 0; - font-size: 0.6875rem; - color: rgba(255, 255, 255, 0.6); -} -.portal-welcome__editor-name { - font-weight: 600; - color: rgba(255, 255, 255, 0.9); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.portal-welcome__editor-sep { - color: rgba(255, 255, 255, 0.35); - flex-shrink: 0; -} -.portal-welcome__editor-pages { + font-size: 0.8125rem; color: rgba(255, 255, 255, 0.55); - flex-shrink: 0; -} -.portal-welcome__editor-page { - font-size: 0.6875rem; - color: rgba(255, 255, 255, 0.55); - flex-shrink: 0; } -.portal-welcome__editor-body { - display: flex; - align-items: stretch; - flex: 1; - min-height: 0; -} - -.portal-welcome__rail { - width: 2.5rem; - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.5rem 0; - color: rgba(255, 255, 255, 0.4); -} -.portal-welcome__editor-body > .portal-welcome__rail:first-child { - border-right: 1px solid rgba(255, 255, 255, 0.06); -} -.portal-welcome__editor-body > .portal-welcome__rail:last-child { - border-left: 1px solid rgba(255, 255, 255, 0.06); -} -.portal-welcome__rail-icon { +/* Header action group: icon buttons + the CTA. */ +.portal-welcome__actions { display: flex; align-items: center; - justify-content: center; - width: 1.625rem; - height: 1.625rem; - border-radius: 0.3125rem; + gap: 0.5rem; } -.portal-welcome__rail-icon:nth-child(2) { - background: rgba(96, 165, 250, 0.18); - color: rgba(147, 197, 253, 0.95); +.portal-welcome__icon-btn { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + flex-shrink: 0; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: #fff; + cursor: pointer; + transition: background var(--motion-fast); +} +.portal-welcome__icon-btn:hover { + background: rgba(255, 255, 255, 0.16); } -/* ── White document preview ── */ -.portal-welcome__doc { - flex: 1; - min-width: 0; - margin: 0.75rem 1rem 0; - padding: 0.875rem 1.125rem; - border-radius: 0.25rem 0.25rem 0 0; +/* White CTA on the dark header, matching the marketing card. */ +.portal-welcome__header .portal-welcome__cta.sui-btn { background: #ffffff; - color: #0f172a; - box-shadow: 0 0.25rem 1.125rem rgba(0, 0, 0, 0.22); + border-color: #ffffff; + color: #16213e; +} +.portal-welcome__header .portal-welcome__cta.sui-btn:hover { + background: rgba(255, 255, 255, 0.88); + border-color: rgba(255, 255, 255, 0.88); } -.portal-welcome__doc-eyebrow { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - font-size: 0.5625rem; - font-weight: 700; - letter-spacing: 0.08em; - color: #dc2626; -} -.portal-welcome__doc-critical { - padding: 0.1875rem 0.5625rem; - border-radius: 0.1875rem; - background: #fee2e2; - letter-spacing: 0.06em; -} -.portal-welcome__doc-title { - margin-bottom: 0.5rem; - font-size: 0.9375rem; - font-weight: 700; - line-height: 1.3; - color: #0f172a; -} -.portal-welcome__doc-divider { - border-top: 1px solid #e8e6e1; - margin-bottom: 0.625rem; -} -.portal-welcome__doc-meta { - display: flex; - gap: 1.5rem; - margin-bottom: 0.75rem; - font-size: 0.625rem; - color: #0f172a; - font-weight: 600; -} -.portal-welcome__doc-meta-key { - color: #9b9a97; - font-weight: 400; -} -.portal-welcome__doc-heading { - margin-bottom: 0.375rem; - font-size: 0.625rem; - font-weight: 700; - letter-spacing: 0.06em; - color: #0f172a; -} -.portal-welcome__doc-body { - margin: 0 0 0.75rem; - font-size: 0.625rem; - line-height: 1.5; - color: #6b6b6b; -} -.portal-welcome__doc-finding { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.4375rem 0.75rem; - border-radius: 0.1875rem; - font-size: 0.625rem; - font-weight: 500; -} -.portal-welcome__doc-finding--critical { - margin-bottom: 0.3125rem; - background: #fee2e2; - color: #7f1d1d; -} -.portal-welcome__doc-finding--high { - background: #fef3c7; - color: #92400e; -} -.portal-welcome__doc-dot { - width: 0.375rem; - height: 0.375rem; - border-radius: 50%; - flex-shrink: 0; -} -.portal-welcome__doc-finding--critical .portal-welcome__doc-dot { - background: #dc2626; -} -.portal-welcome__doc-finding--high .portal-welcome__doc-dot { - background: #d97706; -} - -/* ── Attached footer strip (setup checklist) ── */ +/* ── Steps + enterprise (setup checklist) sit directly under the header ── */ .portal-welcome__footer { - position: relative; - z-index: 2; - border-top: 1px solid var(--color-border-light); background: var(--color-surface); } diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx index 0162d5f262..8dba4caf36 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx +++ b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx @@ -1,6 +1,18 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { SetupChecklist } from "@portal/components/SetupChecklist"; +import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; + +const progress: OnboardingProgress = { + loading: false, + deployed: false, + editorDone: false, + policiesDone: true, + inviteDone: false, + policiesActive: 2, + policiesRecommended: 5, + allComplete: false, +}; const meta: Meta = { title: "Portal/Home/WelcomeBanner", @@ -24,6 +36,6 @@ export const Default: Story = {}; * checklist attached as the footer strip. */ export const WithSetupChecklist: Story = { args: { - footer: , + footer: , }, }; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx index ee64be1011..0d56e85730 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.tsx +++ b/frontend/editor/src/portal/components/WelcomeBanner.tsx @@ -1,129 +1,31 @@ import type { ReactNode } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; import { EDITOR_URL } from "@portal/auth/editorUrl"; import { DownloadIcon, - UsersIcon, - EditorIcon, ExternalLinkIcon, - SearchIcon, - SourcesIcon, - PoliciesIcon, - SparklesIcon, - ComponentsIcon, - DocumentsIcon, - SettingsIcon, + UserPlusIcon, } from "@portal/components/icons"; +import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; +import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; import "@portal/components/WelcomeBanner.css"; /* ──────────────────────────────────────────────────────────────────────── */ -/* Editor-card visual */ +/* Free-tier welcome hero */ /* */ -/* A decorative mock of the deployed PDF Editor reviewing a security */ -/* report. Purely presentational — hidden from assistive tech. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -const RAIL_ICONS = [ - SearchIcon, - SourcesIcon, - PoliciesIcon, - EditorIcon, - ComponentsIcon, - SparklesIcon, - DocumentsIcon, - SettingsIcon, -] as const; - -function EditorRail() { - return ( -
- {RAIL_ICONS.map((Icon, i) => ( - - - - ))} -
- ); -} - -function WelcomeEditorCard() { - return ( -
-
-
- - - -
-
- - - Vulnerability Assessment Report CVE-2026-1847.pdf - - · - 18 pages -
- 1 / 18 -
- -
- -
-
- SECURITY ASSESSMENT - CRITICAL -
-
- Vulnerability Assessment Report CVE-2026-1847 -
-
-
- - - Assessment Date:{" "} - - February 2026 - - - - Classification:{" "} - - FOUO - -
-
EXECUTIVE SUMMARY
-

- This assessment identifies critical vulnerabilities in the target - environment and provides actionable remediation guidance prioritized - by risk severity. -

-
KEY FINDINGS
-
- 3 Critical - vulnerabilities identified -
-
- 7 High-severity issues - requiring attention -
-
- -
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Welcome banner (free-tier hero) */ +/* A compact product header — brand mark, "PDF Editor" + social-proof */ +/* stats, and a single "Open in browser" CTA — over the getting-started */ +/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */ +/* steps, not marketing copy, are the point of the card. */ /* ──────────────────────────────────────────────────────────────────────── */ interface WelcomeBannerProps { /** - * Rendered as an attached footer strip inside the banner card (e.g. the - * "Finish setting up" checklist). Kept as a slot so the hero stays a pure - * presentational shell. + * The getting-started steps + enterprise rung, rendered inside the card + * below the header. Kept as a slot so the hero stays a presentational shell. */ footer?: ReactNode; } @@ -131,64 +33,65 @@ interface WelcomeBannerProps { export function WelcomeBanner({ footer }: WelcomeBannerProps) { const { t } = useTranslation(); const { setActiveView } = useView(); + const [installOpen, setInstallOpen] = useState(false); return (
-
-
-
-
-
-
- -
- - {t("portal.welcome.badge")} +
+
+ + -

- {t("portal.welcome.title")}{" "} - - {t("portal.welcome.titleAccent")} +
+ + {t("portal.welcome.productName")} + + + {t("portal.welcome.stats")} -

-

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

-
- - -
-

{t("portal.welcome.perks")}

- -
- +
+ + +
{footer &&
{footer}
} + + setInstallOpen(false)} + />
); } diff --git a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx index a6b28688e6..27b076eb69 100644 --- a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx +++ b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import FileUploadRounded from "@mui/icons-material/FileUploadRounded"; import { Button, Modal } from "@app/ui"; import "@portal/views/AgentBuilder.css"; @@ -58,7 +59,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) { onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)} /> - ⇪ + {fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")} diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx index f27f62097e..3e6a9859dd 100644 --- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx +++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx @@ -36,10 +36,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {

diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index 0192d5761e..a2027ff756 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -129,6 +129,8 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { onClose={() => setModalOpen(false)} teamId={wallet.teamId} currency={currency} + pricePerDocMinor={wallet.pricePerDocMinor} + initialCapUsd={wallet.capUsd} onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )} diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index 6f4c46641c..d7da10398b 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -28,7 +28,7 @@ export const WithUnsynced: Story = { }, }; -/** Nothing metered yet this period — the split hides. */ +/** Nothing processed yet this period — the split + summary hide. */ export const Empty: Story = { args: { wallet: { @@ -36,6 +36,10 @@ export const Empty: Story = { billableUsed: 0, spendUnitsThisPeriod: 0, categoryBreakdown: { api: 0, ai: 0, automation: 0 }, + categoryDocs: { api: 0, ai: 0, automation: 0 }, + docsProcessedThisPeriod: 0, + uniquePdfsThisPeriod: 0, + sizeMultiplierPdfsThisPeriod: 0, }, }, }; diff --git a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index 00d9806e6a..794ac39ae5 100644 --- a/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -1,19 +1,21 @@ import { useTranslation } from "react-i18next"; import { Card } from "@app/ui"; +import { formatMinor } from "@app/billing"; import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; import type { LocalUsage } from "@portal/api/link"; /** - * "PDFs processed this period" headline + a stacked split of where the metered - * PDFs went. The split reuses the wallet's existing {@code categoryBreakdown} - * (API / Agents / Automation — the same buckets the entitlement service tracks; - * the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when - * nothing metered has run yet. + * "PDFs processed this period" headline (the input-file count) plus a summary line + * separating that count from the size-scaled meter units, and a stacked split of + * where the PDFs went by category (API / Agents / Automation — the "AI" bucket + * surfaces as "Agents" here), driven by the wallet's per-category {@code categoryDocs} + * counts. Real data only: everything hides when nothing has run this period. * - *

When a linked instance has accrued usage SaaS hasn't billed yet ({@code - * unsynced}), it's folded into the headline + split so "current usage" reflects - * work done since the last daily sync. The synced-vs-pending split is an internal - * detail the customer doesn't need, so it's not surfaced — just the combined total. + *

Instance-local usage a linked instance has accrued but SaaS hasn't billed yet + * ({@code unsynced}) is units-only, so it folds into the meter-units figure (and the + * avg-per-PDF that derives from it) but NOT the PDF count or the per-category split, + * which reflect synced processing. The synced-vs-pending distinction is an internal + * detail, so only the combined unit total is surfaced. */ const SEGMENTS: ReadonlyArray<{ key: keyof WalletCategoryBreakdown; @@ -57,17 +59,31 @@ export function PdfsProcessedCard({ unsynced?: LocalUsage | null; }) { const { t } = useTranslation(); - // Fold instance-local unsynced usage into both the headline and the split, so - // the card shows synced + not-yet-billed work as a single current-usage figure. - const pending = unsynced?.totalUnsyncedUnits ?? 0; - const base = wallet.categoryBreakdown; - const b: WalletCategoryBreakdown = { - api: base.api + (unsynced?.apiUnsyncedUnits ?? 0), - ai: base.ai + (unsynced?.aiUnsyncedUnits ?? 0), - automation: base.automation + (unsynced?.automationUnsyncedUnits ?? 0), - }; - const total = b.api + b.ai + b.automation; - const headline = wallet.billableUsed + pending; + // The count dimension (PDFs) is shown separately from the size-scaled meter + // units. Instance-local unsynced usage (combined-billing) is units-only, so it + // folds into the meter-units figure; the PDF count reflects synced processing. + const pendingUnits = unsynced?.totalUnsyncedUnits ?? 0; + const docs = wallet.docsProcessedThisPeriod; + const uniquePdfs = wallet.uniquePdfsThisPeriod; + const meterUnits = wallet.spendUnitsThisPeriod + pendingUnits; + const sizeMultiplierPdfs = wallet.sizeMultiplierPdfsThisPeriod; + + // Per-category PDF counts drive the split ("this many PDFs ran automation / AI / + // API"); units are surfaced in the aggregate summary line, not per bucket. + const perDocs: WalletCategoryBreakdown = wallet.categoryDocs; + const totalDocs = perDocs.api + perDocs.ai + perDocs.automation; + + // Average cost per PDF in minor currency units — meter units × the per-unit rate, + // spread over the input files processed. Shown only when the rate is known (free-tier + // and unknown-price snapshots omit the term rather than imply $0.00). + const rate = wallet.pricePerDocMinor; + const showAvgCost = docs > 0 && rate != null; + const avgCostMinor = + rate != null && docs > 0 ? (meterUnits / docs) * rate : 0; + + // Something ran once there are either counted PDFs or metered units (instance-local + // unsynced usage is units-only, so it keeps the card out of the empty state). + const hasActivity = docs > 0 || meterUnits > 0; return ( @@ -78,66 +94,99 @@ export function PdfsProcessedCard({ )}

- - {headline.toLocaleString()} - + {docs.toLocaleString()} - {t("portal.billing.pdfsProcessed.unit", "metered PDFs")} + {t("portal.billing.pdfsProcessed.unit", "PDFs")}
- {total > 0 ? ( + {hasActivity ? ( <> -
- {SEGMENTS.map((s) => - b[s.key] > 0 ? ( - - ) : null, - )} -
-
- {SEGMENTS.map((s) => ( -
- - - {t(s.labelKey, s.labelDefault)} - - - {t( - "portal.billing.pdfsProcessed.legendValue", - "{{formatted}} PDFs", - { - count: b[s.key], - formatted: b[s.key].toLocaleString(), - }, - )} - - - {t(s.descKey, s.descDefault)} - +

+ {showAvgCost + ? t( + "portal.billing.pdfsProcessed.summary", + "{{unique}} unique · {{units}} meter units · {{avg}} avg per PDF", + { + unique: uniquePdfs.toLocaleString(), + units: meterUnits.toLocaleString(), + avg: formatMinor(avgCostMinor, wallet.currency), + }, + ) + : t( + "portal.billing.pdfsProcessed.summaryNoRate", + "{{unique}} unique · {{units}} meter units", + { + unique: uniquePdfs.toLocaleString(), + units: meterUnits.toLocaleString(), + }, + )} +

+ {totalDocs > 0 ? ( + <> +
+ {SEGMENTS.map((s) => + perDocs[s.key] > 0 ? ( + + ) : null, + )}
- ))} -
+
+ {SEGMENTS.map((s) => ( +
+ + + {t(s.labelKey, s.labelDefault)} + + + {t( + "portal.billing.pdfsProcessed.legendValue", + "{{formatted}} PDFs", + { + count: perDocs[s.key], + formatted: perDocs[s.key].toLocaleString(), + }, + )} + + + {t(s.descKey, s.descDefault)} + +
+ ))} +
+ + ) : null} + {sizeMultiplierPdfs > 0 ? ( +

+ {t( + "portal.billing.pdfsProcessed.sizeMultiplier", + "{{formatted}} PDFs used a size multiplier", + { formatted: sizeMultiplierPdfs.toLocaleString() }, + )} +

+ ) : null} ) : (

{t( "portal.billing.pdfsProcessed.emptyPeriod", - "No metered processing yet this period.", + "No processing yet this period.", )}

)} diff --git a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx index a47b3df65b..af1ed1faa0 100644 --- a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx @@ -13,3 +13,8 @@ type Story = StoryObj; /** Actual spend + the Enterprise upsell tacked onto the foot. */ export const Default: Story = { args: { wallet: subscribedWallet } }; + +/** Subscribed but still holding leftover lifetime free grant — shows the free-remaining note. */ +export const WithFreeRemaining: Story = { + args: { wallet: { ...subscribedWallet, freeRemaining: 380 } }, +}; diff --git a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx index d4a969ae0c..10aebbe32f 100644 --- a/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx @@ -46,6 +46,18 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { }, )}

+ {wallet.freeRemaining > 0 ? ( +

+ {t( + "portal.billing.spendThisMonth.freeRemaining", + "{{formatted}} free PDFs remaining", + { + count: wallet.freeRemaining, + formatted: wallet.freeRemaining.toLocaleString(), + }, + )} +

+ ) : null}
diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx new file mode 100644 index 0000000000..32d0695256 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutModal"; +import "@portal/components/billing/billing.css"; + +/** + * Two-step "turn on the Processor" flow. Step 1 sets the monthly spend cap + * (its own page, since Stripe owns the card iframe on step 2). Storybook has no + * Stripe key, so step 2 renders the card placeholder rather than the live + * embedded form. + */ +const meta: Meta = { + title: "Portal/Billing/StripeCheckoutModal", + component: StripeCheckoutModal, + args: { + open: true, + onClose: () => console.log("close"), + teamId: 1, + currency: "usd", + pricePerDocMinor: 1, + initialCapUsd: 100, + onComplete: () => Promise.resolve(true), + }, + // Let the cap step's "Continue" succeed so the flow can advance to payment. + parameters: { + layout: "fullscreen", + msw: { + handlers: [ + http.patch( + "http://saas.mock/api/v1/payg/cap", + () => new HttpResponse(null, { status: 204 }), + ), + ], + }, + }, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 — set the monthly spend limit before adding a card. */ +export const SetCap: Story = {}; + +/** Step 1 seeded with no cap (uncapped) selected. */ +export const Uncapped: Story = { args: { initialCapUsd: null } }; diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 7e8e55e70a..65d32ed8ea 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -1,16 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui"; +import { SpendCapControl } from "@app/billing"; import { EmbeddedCheckout, EmbeddedCheckoutProvider, } from "@stripe/react-stripe-js"; import type { Stripe } from "@stripe/stripe-js"; +import { updateCap } from "@portal/api/billing"; import { createCheckoutSession, getStripePublishableKey, type SaasCurrency, } from "@portal/billing/stripe"; +import { LockIcon } from "@portal/components/icons"; interface Props { open: boolean; @@ -19,27 +22,34 @@ interface Props { teamId: number; /** "usd" | "eur" | "gbp" — the SaaS PAYG offering's supported set. */ currency: SaasCurrency; + /** Per-document rate in minor units, for the cap→PDF estimate on step 1. */ + pricePerDocMinor?: number | null; + /** Cap to seed the spend-limit step with; defaults to $100/mo. */ + initialCapUsd?: number | null; /** Optional billing email prefill (Stripe locks the field when set). */ billingOwnerEmail?: string; /** * Fired when Stripe (or the mock continue button) signals payment success. * Runs the caller's activation flow (poll the wallet until the subscription * webhook lands) and resolves {@code true} once subscribed, {@code false} if - * it's taking longer than the poll window. The modal stays open and - * non-dismissable while this runs, so the admin watches activation through - * instead of the modal vanishing and needing a manual refresh. + * it's taking longer than the poll window. */ onComplete: () => Promise; } +/** Preset monthly caps offered on the spend-limit step (major currency units). */ +const CAP_PRESETS = [50, 100, 250, 1000] as const; +const DEFAULT_CAP_USD = 100; + /** - * Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We - * fetch a {@code client_secret} from the SaaS Supabase edge function then - * mount <EmbeddedCheckoutProvider> inline — no full-page redirect, the - * admin stays in the portal. + * Two-step "turn on the Processor" flow. Stripe's embedded Checkout owns the card + * form in its own iframe, so we can't add our own fields to it — instead the cap + * lives on step 1 (its own page) and the embedded card form on step 2. The chosen + * ceiling is applied before payment so "you're never billed past it" is true from + * the first processed PDF. * - * If the team is already subscribed the edge function short-circuits to a - * Stripe Customer Portal URL; we open it in a new tab and close the modal. + * If the team is already subscribed the edge function short-circuits to a Stripe + * Customer Portal URL; we open it in a new tab and close. */ let stripePromise: Promise | null = null; function loadStripeOnce(pk: string): Promise { @@ -49,6 +59,22 @@ function loadStripeOnce(pk: string): Promise { return stripePromise; } +/** Two-segment progress header: step 1 = spend limit, step 2 = payment. */ +function StepProgress({ step }: { step: 1 | 2 }) { + const { t } = useTranslation(); + return ( +
+
+ + = 2 ? "is-done" : ""} /> +
+ + {t("portal.billing.checkout.stepCount", "Step {{step}} of 2", { step })} + +
+ ); +} + /** Payment done, waiting for the subscription webhook to activate the plan. */ function CheckoutFinalizing() { const { t } = useTranslation(); @@ -98,11 +124,45 @@ function CheckoutActivationSlow({ onClose }: { onClose: () => void }) { ); } +/** + * Placeholder for the card form when no Stripe publishable key is configured + * (Storybook / preview / mis-config). Mirrors the real embedded form's framing + * so the step reads correctly without mounting Stripe. + */ +function CardPlaceholder() { + const { t } = useTranslation(); + return ( +
+
+ {t("portal.billing.checkout.card.label", "Card details")} + Stripe +
+
+ + + {t( + "portal.billing.checkout.card.fields", + "Card number · MM / YY · CVC · ZIP", + )} + +
+

+ {t( + "portal.billing.checkout.card.note", + "Card details collected by Stripe. Stirling never stores PAN or CVC.", + )} +

+
+ ); +} + export function StripeCheckoutModal({ open, onClose, teamId, currency, + pricePerDocMinor, + initialCapUsd, billingOwnerEmail, onComplete, }: Props) { @@ -110,11 +170,17 @@ export function StripeCheckoutModal({ const [clientSecret, setClientSecret] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // "checkout" = Stripe form; "finalizing" = payment done, waiting for the plan to activate - // (non-dismissable); "activationSlow" = webhook lagging past the poll window (dismissable). + // "cap" = spend-limit step; "checkout" = Stripe card form; "finalizing" = + // payment done, waiting for activation (non-dismissable); "activationSlow" = + // webhook lagging past the poll window (dismissable). const [phase, setPhase] = useState< - "checkout" | "finalizing" | "activationSlow" - >("checkout"); + "cap" | "checkout" | "finalizing" | "activationSlow" + >("cap"); + const [capUsd, setCapUsd] = useState( + initialCapUsd ?? DEFAULT_CAP_USD, + ); + const [capBusy, setCapBusy] = useState(false); + const [capError, setCapError] = useState(null); const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -125,16 +191,24 @@ export function StripeCheckoutModal({ const publishableKey = getStripePublishableKey(); - // Mint the checkout session whenever the modal opens for a fresh team/currency. + // Reset to the spend-limit step whenever the modal closes, so re-opening starts fresh. useEffect(() => { if (!open) { - // Reset on close so re-opening fetches a fresh session + starts at checkout. + setPhase("cap"); setClientSecret(null); setError(null); setLoading(true); - setPhase("checkout"); - return; + setCapUsd(initialCapUsd ?? DEFAULT_CAP_USD); + setCapBusy(false); + setCapError(null); } + }, [open, initialCapUsd]); + + // Mint the checkout session only once the user has set a cap and advanced to + // the payment step — not on open — so we don't create a session they may abandon + // on step 1, and so the cap is applied first. + useEffect(() => { + if (!open || phase !== "checkout" || !publishableKey) return; let cancelled = false; setLoading(true); setError(null); @@ -148,8 +222,6 @@ export function StripeCheckoutModal({ .then((session) => { if (cancelled) return; if (session.alreadySubscribed && session.redirectUrl) { - // Team's already on PAYG — bounce them to the management portal - // instead of mounting a checkout iframe with no secret. window.open(session.redirectUrl, "_blank", "noopener,noreferrer"); onClose(); return; @@ -166,9 +238,7 @@ export function StripeCheckoutModal({ setClientSecret(session.clientSecret); }) .catch((e) => { - if (!cancelled) { - setError(e instanceof Error ? e.message : String(e)); - } + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (!cancelled) setLoading(false); @@ -176,14 +246,37 @@ export function StripeCheckoutModal({ return () => { cancelled = true; }; - }, [open, teamId, currency, billingOwnerEmail, onClose, t]); + }, [ + open, + phase, + publishableKey, + teamId, + currency, + billingOwnerEmail, + onClose, + t, + ]); const stripe = publishableKey ? loadStripeOnce(publishableKey) : null; const canRender = Boolean(stripe && clientSecret); - // Payment succeeded — hold the modal open and run the caller's activation poll instead of - // closing. The parent swaps to the subscribed view on success (unmounting us); a lagging - // webhook drops us into the dismissable "almost there" state. + // Apply the chosen ceiling, then advance to payment. Applying it up front keeps + // "you're never billed past it" true from the first processed PDF. + async function handleContinue() { + setCapBusy(true); + setCapError(null); + try { + await updateCap(capUsd); + if (mounted.current) setPhase("checkout"); + } catch (e) { + if (mounted.current) { + setCapError(e instanceof Error ? e.message : String(e)); + } + } finally { + if (mounted.current) setCapBusy(false); + } + } + function handleStripeComplete() { setPhase("finalizing"); onComplete() @@ -195,13 +288,26 @@ export function StripeCheckoutModal({ }); } - // Block dismissal while activation is in flight so a half-finished flow can't be abandoned; - // the X, backdrop, and Escape all route through this. + // Block dismissal while activation is in flight so a half-finished flow can't be abandoned. const dismissable = phase !== "finalizing"; const handleClose = () => { if (dismissable) onClose(); }; + const onCapStep = phase === "cap"; + const title = onCapStep + ? t("portal.billing.checkout.cap.title", "Set your spend limit") + : t("portal.billing.checkout.title", "Add a payment method"); + const subtitle = onCapStep + ? t( + "portal.billing.checkout.cap.subtitle", + "You're billed only for PDFs you process past your first 500 free, never for seats — so your ceiling is yours from day one.", + ) + : t( + "portal.billing.checkout.subtitle", + "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest.", + ); + return ( {phase === "finalizing" && } - {phase === "activationSlow" && ( )} - {phase === "checkout" && ( - <> - {!publishableKey && ( + {phase === "cap" && ( +
+ + +

+ {t( + "portal.billing.checkout.cap.finePrint", + "Invoices post on the 1st. Cancel anytime and revert to the Editor plan; your policies and history stay intact.", + )} +

+ {capError && ( - {t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "} - VITE_STRIPE_PUBLISHABLE_KEY{" "} - {t( - "portal.billing.checkout.notConfigured.bodyAfter", - "in the portal env to enable in-app checkout.", - )} + {capError} )} +
+ + +
+
+ )} + + {phase === "checkout" && ( +
+ + {!publishableKey && } {publishableKey && error && ( )} - +
)}
); diff --git a/frontend/editor/src/portal/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css index 63ea82d748..1809a4f80c 100644 --- a/frontend/editor/src/portal/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -116,6 +116,13 @@ margin: 0 0 1rem; } +/* Free grant still offsetting spend — reads as a positive credit note, pulled up + under the "PDFs processed" line. */ +.portal-billing__free-remaining { + color: var(--color-green-600, var(--color-green)); + margin-top: -0.5rem; +} + .portal-billing__skeleton { display: flex; flex-direction: column; @@ -1038,3 +1045,97 @@ .portal-billing__projection strong { color: var(--color-amber, #d97706); } + +/* ── Checkout modal: 2-step (spend limit → payment) ──────────────────── */ +.portal-billing__checkout-cap, +.portal-billing__checkout-pay { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.portal-billing__checkout-progress { + display: flex; + align-items: center; + gap: 0.75rem; +} +.portal-billing__checkout-steps { + display: flex; + gap: 0.375rem; + flex: 1; +} +.portal-billing__checkout-steps span { + height: 4px; + flex: 1; + border-radius: var(--radius-pill); + background: var(--color-border); +} +.portal-billing__checkout-steps span.is-done { + background: var(--color-blue); +} +.portal-billing__checkout-stepcount { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-5); + white-space: nowrap; +} + +.portal-billing__checkout-finePrint { + margin: 0; + font-size: 0.75rem; + line-height: 1.55; + color: var(--color-text-5); +} +.portal-billing__checkout-cap-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 0.25rem; +} + +/* Card-form placeholder shown when Stripe isn't configured (Storybook / preview). */ +.portal-billing__card-placeholder { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-md); + background: var(--color-surface); +} +.portal-billing__card-placeholder-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-2); +} +.portal-billing__card-placeholder-badge { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-5); + background: var(--color-bg-code); + padding: 0.125rem 0.375rem; + border-radius: var(--radius-sm); +} +.portal-billing__card-placeholder-field { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 0.875rem; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--color-text-5); +} +.portal-billing__card-placeholder-note { + margin: 0; + font-size: 0.6875rem; + line-height: 1.5; + color: var(--color-text-5); +} diff --git a/frontend/editor/src/portal/components/billing/walletFixtures.ts b/frontend/editor/src/portal/components/billing/walletFixtures.ts index c751f1269a..cd754af3fc 100644 --- a/frontend/editor/src/portal/components/billing/walletFixtures.ts +++ b/frontend/editor/src/portal/components/billing/walletFixtures.ts @@ -19,6 +19,10 @@ export const freeWallet: Wallet = { stripeSubscriptionId: null, spendUnitsThisPeriod: 120, categoryBreakdown: { api: 40, ai: 30, automation: 50 }, + categoryDocs: { api: 30, ai: 20, automation: 40 }, + docsProcessedThisPeriod: 90, + uniquePdfsThisPeriod: 84, + sizeMultiplierPdfsThisPeriod: 12, members: [], recent: [], }; @@ -42,6 +46,10 @@ export const subscribedWallet: Wallet = { stripeSubscriptionId: "sub_123", spendUnitsThisPeriod: 2250, categoryBreakdown: { api: 900, ai: 600, automation: 750 }, + categoryDocs: { api: 700, ai: 450, automation: 600 }, + docsProcessedThisPeriod: 1750, + uniquePdfsThisPeriod: 1600, + sizeMultiplierPdfsThisPeriod: 320, members: [ { userId: "u1", diff --git a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx index e43076dae3..3c6de80c93 100644 --- a/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx +++ b/frontend/editor/src/portal/components/documents/DocumentExtractions.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import LockRounded from "@mui/icons-material/LockRounded"; import { StatusBadge, Table, type TableColumn } from "@app/ui"; import { type Extraction, type ReviewDocument } from "@portal/api/documents"; import { @@ -55,7 +56,7 @@ export function DocumentExtractions({ return (
- 🔒 +

{t("portal.documents.extractions.masked")} diff --git a/frontend/editor/src/portal/components/documents/ElevationBanner.tsx b/frontend/editor/src/portal/components/documents/ElevationBanner.tsx index 429cd606cb..67a26ad1c2 100644 --- a/frontend/editor/src/portal/components/documents/ElevationBanner.tsx +++ b/frontend/editor/src/portal/components/documents/ElevationBanner.tsx @@ -1,4 +1,6 @@ import { useTranslation } from "react-i18next"; +import ScheduleRounded from "@mui/icons-material/ScheduleRounded"; +import LockRounded from "@mui/icons-material/LockRounded"; import { Banner, Button } from "@app/ui"; import { formatCountdown } from "@portal/components/documents/format"; @@ -26,7 +28,7 @@ export function ElevationBanner({ return ( ⏱} + icon={} title={t("portal.documents.elevation.active.title", { time: formatCountdown(secondsLeft), })} @@ -42,7 +44,7 @@ export function ElevationBanner({ return ( 🔒} + icon={} title={t("portal.documents.elevation.gated.title")} description={ fourEyes diff --git a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx index dffa7305d7..bff13d0c4f 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import LockRounded from "@mui/icons-material/LockRounded"; import { Button, Chip, StatusBadge, Table, type TableColumn } from "@app/ui"; import { classificationTone, @@ -75,7 +76,7 @@ export function ReviewQueueTable({ title={t("portal.documents.table.sensitiveTitle")} aria-label={t("portal.documents.table.sensitiveLabel")} > - 🔒 + )}

diff --git a/frontend/editor/src/portal/components/icons.tsx b/frontend/editor/src/portal/components/icons.tsx index c5e93b8f08..8f8609648c 100644 --- a/frontend/editor/src/portal/components/icons.tsx +++ b/frontend/editor/src/portal/components/icons.tsx @@ -273,6 +273,15 @@ export function LinkIcon(props: IconProps) { ); } +export function LockIcon(props: IconProps) { + return ( + + + + + ); +} + export function UserPlusIcon(props: IconProps) { return ( diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx index 221ad0543a..ce78ded6fa 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx @@ -15,25 +15,29 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { + + {KEY_LABEL[apiKey.status]} + + + › + + + } > {apiKey.name} {apiKey.prefix} - - - {t(KEY_LABEL[apiKey.status])} - - - › - - {open && ( diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index 07bff185da..b27d57995e 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -46,6 +46,7 @@ export function AuditTab() { key: "elevation", label: t("portal.infrastructure.audit.filters.elevation"), }, + { key: "policy", label: t("portal.infrastructure.audit.filters.policy") }, { key: "processing", label: t("portal.infrastructure.audit.filters.processing"), @@ -146,12 +147,12 @@ export function AuditTab() { value={data.summary.totalEvents.toLocaleString()} /> = { auth: "portal.infrastructure.auditCatLabel.auth", config: "portal.infrastructure.auditCatLabel.config", elevation: "portal.infrastructure.auditCatLabel.elevation", + policy: "portal.infrastructure.auditCatLabel.policy", processing: "portal.infrastructure.auditCatLabel.processing", security: "portal.infrastructure.auditCatLabel.security", }; @@ -129,6 +130,7 @@ export const AUDIT_CAT_TONE: Record = { auth: "info", config: "neutral", elevation: "purple", + policy: "purple", processing: "success", security: "warning", }; diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx index 42550e28a4..38c46547ca 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import AccountTreeRounded from "@mui/icons-material/AccountTreeRounded"; import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; import { Chip, @@ -31,7 +32,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) { render: (p) => (
- ⛓ +
{p.name} diff --git a/frontend/editor/src/portal/components/pipelines/outputModes.ts b/frontend/editor/src/portal/components/pipelines/outputModes.ts new file mode 100644 index 0000000000..a8e698c676 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/outputModes.ts @@ -0,0 +1,11 @@ +import type { PipelineOutputMode } from "@portal/api/pipelines"; + +/** + * The output destinations the pipeline builder offers. An extension point: + * deployments where a destination cannot work shadow this module and filter + * the list (e.g. hosted deployments never write to the server's filesystem, + * so folder outputs are not offered there). + */ +export function availableOutputModes(): PipelineOutputMode[] { + return ["inline", "folder", "s3"]; +} diff --git a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx index 0ef87d77a9..b3c0b2c622 100644 --- a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx @@ -51,7 +51,7 @@ export function PolicyFieldRow({ size="sm" onClick={() => toggle(opt)} > - {t(`policies.fieldOption.${field.key}.${opt}`, opt)} + {t(`policyOption.${opt}`, opt)} ))}
@@ -67,7 +67,7 @@ export function PolicyFieldRow({ value={typeof value === "string" ? value : ""} options={(field.options ?? []).map((o) => ({ value: o, - label: t(`policies.fieldOption.${field.key}.${o}`, o), + label: t(`policyOption.${o}`, o), }))} onChange={(value) => onChange(value ?? "")} /> diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index e87d25dc9e..9dada09290 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -19,7 +19,11 @@ import { type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; -import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy"; +import { + deserializeToolStep, + serializeStepFromEndpoint, +} from "@app/hooks/tools/shared/toolAutomation"; import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; @@ -121,7 +125,10 @@ const CAPABILITY_META: Record< }, }; -function seedTools(entry: CatalogueEntry): ToolState[] { +function seedTools( + entry: CatalogueEntry, + registry: Partial, +): ToolState[] { const savedSteps = entry.policy?.steps ?? []; const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); // Always use defaultOperations as the canonical list so tools added after a @@ -135,7 +142,12 @@ function seedTools(entry: CatalogueEntry): ToolState[] { : savedSteps.length > 0 ? false : !DISABLED_BY_DEFAULT.has(s.operation), - parameters: saved?.parameters ?? s.parameters, + // Saved steps are in the backend contract shape; map them back to the UI + // shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`). + // Presets are already authored in the UI shape, so use them as-is. + parameters: saved + ? deserializeToolStep(saved, registry).params + : s.parameters, }; }); } @@ -190,7 +202,9 @@ function PolicySetupWizardBody({ const isEdit = policy != null; const [step, setStep] = useState("workflow"); - const [tools, setTools] = useState(() => seedTools(entry)); + const [tools, setTools] = useState(() => + seedTools(entry, toolRegistry), + ); const [fieldValues, setFieldValues] = useState(() => resolveFieldValues(entry), ); @@ -263,10 +277,12 @@ function PolicySetupWizardBody({ } setError(null); setSubmitting(true); - const steps: PipelineStep[] = enabledTools.map((tl) => ({ - operation: tl.operation, - parameters: tl.parameters, - })); + // Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the + // backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the + // UI shape verbatim would drop those fields and the step would run with none. + const steps: PipelineStep[] = enabledTools.map((tl) => + serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry), + ); try { await onSubmit(entry, { fieldValues, diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 5035ec49b1..9421de28bb 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -5,6 +5,8 @@ import type { ProcurementSnapshot } from "@portal/api/procurement"; const base: ProcurementSnapshot = { dealId: 1, stage: "trial", + deployment: "cloud", + seats: 250, trialStartedAt: "2026-06-25T00:00:00Z", trialEndsAt: "2026-07-09T00:00:00Z", trialExtensionsUsed: 0, @@ -21,7 +23,7 @@ const meta: Meta = { args: { canSchedule: true, onExpand: () => {}, - onKeyDocs: () => {}, + onLicense: () => {}, onInvite: () => {}, onSchedule: () => {}, onManageTrial: () => {}, diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 56e9de6d70..e2a3e19687 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -1,13 +1,16 @@ import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import type { ViewId } from "@portal/contexts/ViewContext"; -import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement"; +import { + FLOW_JOURNEY, + type ProcurementSnapshot, +} from "@portal/api/procurement"; import { StageStepper } from "@portal/components/procurement/StageStepper"; import "@portal/views/Procurement.css"; /** * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the - * deal stage: quick-action chips (trial countdown → manage, key documents, invite teammates, + * deal stage: quick-action chips (trial countdown → manage, licence key, invite teammates, * schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that * expands the flow into the takeover modal. Matches the marketing prototype. */ @@ -16,7 +19,7 @@ export function DealStatusHero({ busy = false, canSchedule, onExpand, - onKeyDocs, + onLicense, onInvite, onSchedule, onManageTrial, @@ -28,7 +31,7 @@ export function DealStatusHero({ * "Schedule a call" action only appears when the org has linked its account. */ canSchedule: boolean; onExpand: () => void; - onKeyDocs: () => void; + onLicense: () => void; onInvite: () => void; onSchedule: () => void; onManageTrial: () => void; @@ -42,11 +45,9 @@ export function DealStatusHero({ ? t("portal.procurement.hero.ctaTrial") : stage === "quote" ? t("portal.procurement.hero.ctaQuote") - : stage === "security" - ? t("portal.procurement.hero.ctaAgreement") - : stage === "procurement" - ? t("portal.procurement.hero.ctaPayment") - : t("portal.procurement.hero.ctaLive"); + : stage === "procurement" + ? t("portal.procurement.hero.ctaPayment") + : t("portal.procurement.hero.ctaLive"); const setupSteps: { title: string; sub: string; view: ViewId }[] = [ { @@ -89,13 +90,13 @@ export function DealStatusHero({ })} )} - {stage !== "active" && ( + {snapshot.licenseKey && ( )} {stage !== "active" && ( @@ -120,7 +121,7 @@ export function DealStatusHero({
- +
{inTrial && ( diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 640fcdb2e3..336b6af5cb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -15,16 +15,24 @@ import "@portal/views/Procurement.css"; export function ProcurementAgreement({ quote, busy, + downloading, onAgree, + onDownload, + onEdit, }: { quote: QuoteResult; busy: boolean; + downloading: boolean; + /** Accept the quote straight into a committed subscription (this is also the agreement). */ onAgree: () => void; + onDownload: () => void; + onEdit: () => void; }) { const { t } = useTranslation(); const [checked, setChecked] = useState(false); const annual = money(quote.annualNetMinor, quote.currency); const tcv = money(quote.tcvMinor, quote.currency); + const renewal = money(quote.renewalAnnualNetMinor, quote.currency); const years = quote.config.termYears; return ( @@ -73,7 +81,19 @@ export function ProcurementAgreement({ ))} -

3. End-User License Agreement

+

3. Term, renewal and annual fee adjustment

+

+ This Agreement runs for the committed {years}-year term set out in the + Order Form. It then renews automatically for successive one-year terms + unless either party gives written notice of non-renewal at least 30 + days before the end of the then-current term. On each renewal the + annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment. + Based on this quote, the first renewal year would be approximately{" "} + {renewal} per year; the committed term above is + billed at the rate in the Order Form and is not affected. +

+ +

4. End-User License Agreement

Subject to the terms of this Agreement, Stirling grants Customer a non-exclusive, non-transferable right to use the Service for its @@ -82,7 +102,7 @@ export function ProcurementAgreement({ Service, and all intellectual property in it, remains Stirling's.

-

4. Data Processing Agreement

+

5. Data Processing Agreement

Where Stirling processes personal data on Customer's behalf, it does so only on Customer's documented instructions and applies appropriate @@ -92,7 +112,7 @@ export function ProcurementAgreement({ reference.

-

5. Acceptance

+

6. Acceptance

By agreeing below, Customer accepts this Agreement and the Order Form. On acceptance, Stirling will issue the committed annual subscription @@ -120,6 +140,12 @@ export function ProcurementAgreement({ > {t("portal.procurement.agreement.agreeCta")} + +

); diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index 902bb14c0d..d47ffa0b48 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -22,7 +22,7 @@ export function ControlledDealStatusHero({ busy={controller.busy} canSchedule={controller.isLinked} onExpand={() => controller.setOpen(true)} - onKeyDocs={() => controller.setExtra("docs")} + onLicense={() => controller.setExtra("license")} onInvite={() => setActiveView("users")} onSchedule={() => controller.setExtra("schedule")} onManageTrial={() => controller.setExtra("trial")} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 048bc2d606..44bbea1264 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,17 +1,17 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import type { ProcurementSnapshot } from "@portal/api/procurement"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; +import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; import "@portal/views/Procurement.css"; /** - * Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule - * a call, and trial management. Schedule a call embeds the live Calendly scheduler; Key documents is - * still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves - * like the marketing prototype. + * Small centred dialogs that hang off the deal-status hero's quick actions — the licence key, + * schedule a call, trial management, and trial setup. Schedule a call embeds the live Calendly + * scheduler. The shells and wiring are real so the hero behaves like the marketing prototype. */ function SideModal({ @@ -74,124 +74,45 @@ function SideModal({ ); } -// ── Key documents ──────────────────────────────────────────────────────────── -type DocStatus = "available" | "action" | "request"; -interface DocRow { - nameKey: string; - subKey: string; - status: DocStatus; - fee?: number; -} -// Static demo catalogue for the pilot; the copy lives in the locale under -// portal.procurement.keyDocs and is resolved via t() at render time. -const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [ - { - groupKey: "portal.procurement.keyDocs.groups.yourDeal", - docs: [ - { - nameKey: "portal.procurement.keyDocs.docs.formalQuote.name", - subKey: "portal.procurement.keyDocs.docs.formalQuote.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.msa.name", - subKey: "portal.procurement.keyDocs.docs.msa.sub", - status: "action", - }, - { - nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name", - subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name", - subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub", - status: "request", - }, - ], - }, - { - groupKey: "portal.procurement.keyDocs.groups.evaluation", - docs: [ - { - nameKey: "portal.procurement.keyDocs.docs.soc2.name", - subKey: "portal.procurement.keyDocs.docs.soc2.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.securityReview.name", - subKey: "portal.procurement.keyDocs.docs.securityReview.sub", - status: "request", - fee: 5000, - }, - { - nameKey: "portal.procurement.keyDocs.docs.baa.name", - subKey: "portal.procurement.keyDocs.docs.baa.sub", - status: "request", - fee: 2500, - }, - { - nameKey: "portal.procurement.keyDocs.docs.w9.name", - subKey: "portal.procurement.keyDocs.docs.w9.sub", - status: "available", - }, - { - nameKey: "portal.procurement.keyDocs.docs.coi.name", - subKey: "portal.procurement.keyDocs.docs.coi.sub", - status: "available", - }, - ], - }, -]; -const STATUS_LABEL: Record = { - available: "portal.procurement.keyDocs.status.available", - action: "portal.procurement.keyDocs.status.action", - request: "portal.procurement.keyDocs.status.request", -}; - -export function KeyDocumentsModal({ +// ── Licence key ────────────────────────────────────────────────────────────── +export function LicenseModal({ open, onClose, + licenseKey, + offlineAvailable, + downloadingLicense, + onDownloadOffline, + trial = false, }: { open: boolean; onClose: () => void; + licenseKey: string; + offlineAvailable: boolean; + downloadingLicense: boolean; + onDownloadOffline: () => void; + /** Licence is still the trial one (not yet upgraded on accept) — the downloadable .lic is a + * snapshot, so warn that it must be re-downloaded once the agreement is in place. */ + trial?: boolean; }) { const { t } = useTranslation(); return ( - {STAGE_DOCS.map((g) => ( -
-
{t(g.groupKey)}
-
    - {g.docs.map((d) => ( -
  • -
    - {t(d.nameKey)} - - {t(d.subKey)} - {d.fee - ? t("portal.procurement.keyDocs.oneTimeFee", { - amount: `$${d.fee.toLocaleString()}`, - }) - : ""} - -
    - - {t(STATUS_LABEL[d.status])} - -
  • - ))} -
-
- ))} + + {offlineAvailable && trial && ( +

+ {t("portal.procurement.license.trialFileHint")} +

+ )}
); } @@ -221,6 +142,97 @@ export function ScheduleCallModal({ ); } +// ── Trial setup ──────────────────────────────────────────────────────────── +const DEPLOYMENTS = ["cloud", "selfhost", "airgap"] as const; + +/** + * Captured before the trial starts: where the buyer will run Stirling (which drives the deployment + * fee and, for air-gapped, the offline licence) and their team size. Both seed the quote builder so + * it opens on their real environment; the trial only begins once this is confirmed. + */ +export function TrialSetupModal({ + open, + onClose, + busy, + onConfirm, +}: { + open: boolean; + onClose: () => void; + busy: boolean; + onConfirm: (deployment: string, seats: number) => void; +}) { + const { t } = useTranslation(); + const [deployment, setDeployment] = useState("cloud"); + const [seats, setSeats] = useState(""); + + // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. + useEffect(() => { + if (open) { + setDeployment("cloud"); + setSeats(""); + } + }, [open]); + + return ( + onConfirm(deployment, Math.max(0, Number(seats) || 0))} + > + {t("portal.procurement.setup.start")} + + } + > + + + +

+ {t("portal.procurement.setup.seatsHint")} +

+
+ ); +} + // ── Trial management ───────────────────────────────────────────────────────── export function TrialManageModal({ open, diff --git a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx index 94ee979b86..d1527c32bb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementFlow.tsx @@ -2,19 +2,18 @@ import { useTranslation } from "react-i18next"; import { Banner, Button, EmptyState, Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; -import { JOURNEY } from "@portal/api/procurement"; +import { FLOW_JOURNEY } from "@portal/api/procurement"; import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement"; import { - KeyDocumentsModal, + LicenseModal, ScheduleCallModal, TrialManageModal, + TrialSetupModal, } from "@portal/components/procurement/ProcurementExtras"; import { ProcurementModal } from "@portal/components/procurement/ProcurementModal"; import { - LicensePanel, LiveStageCard, PaymentStageCard, - QuoteMilestoneCard, } from "@portal/components/procurement/ProcurementStages"; import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder"; import { StageStepper } from "@portal/components/procurement/StageStepper"; @@ -22,7 +21,7 @@ import type { ProcurementController } from "@portal/components/procurement/usePr /** * The procurement takeover flow: the full-screen journey modal (quote builder → - * milestone → agreement → payment → live) plus the key-documents, schedule-call, + * quote & agreement → payment → live) plus the licence-key, schedule-call, trial-setup, * and trial-management modals. Driven entirely by a shared ProcurementController * so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the * tier hero card on Home). @@ -56,12 +55,11 @@ export function ProcurementFlow({ extra, setExtra, invoicePdf, + onConfirmSetup, onExtendTrial, onReset, onGenerate, - onAcceptQuote, onAgree, - onGoLive, onDownloadPdf, onDownloadOfflineLicense, } = controller; @@ -106,70 +104,66 @@ export function ProcurementFlow({ {isLinked && started && ( <>
- +
{(editing || (isDraft && (stage === "trial" || stage === "quote"))) && ( )} - {!editing && isIssued && stage === "quote" && latest && ( - setEditing(true)} - /> - )} - - {!editing && stage === "security" && latest && ( - - )} + {/* Quote + agreement are one step: review the itemised quote and the agreement, then + accept straight into a committed subscription. Once accepted you can't go back. + ("security" is the retired agreement stage — still handled so an older deal that + stopped there isn't left blank.) */} + {!editing && + isIssued && + (stage === "quote" || stage === "security") && + latest && ( + setEditing(true)} + /> + )} {!editing && stage === "procurement" && latest && ( )} {!editing && stage === "active" && } - - {data?.licenseKey && ( - - )} - -
- -
)} - setExtra(null)} + busy={busy} + onConfirm={onConfirmSetup} /> + {data?.licenseKey && ( + setExtra(null)} + licenseKey={data.licenseKey} + offlineAvailable={data.deployment === "airgap"} + downloadingLicense={downloadingLicense} + onDownloadOffline={onDownloadOfflineLicense} + trial={data.stage !== "procurement" && data.stage !== "active"} + /> + )} setExtra(null)} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index bd5c0e3fc5..35460a7ba5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -1,8 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Card } from "@app/ui"; -import type { QuoteResult } from "@portal/api/procurement"; -import { money } from "@portal/components/procurement/format"; import "@portal/views/Procurement.css"; /** @@ -11,97 +9,13 @@ import "@portal/views/Procurement.css"; * a pure presentational view driven by props; ProcurementHome owns the state and the actions. */ -/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */ -export function QuoteMilestoneCard({ - quote, - busy, - downloading, - onAccept, - onDownload, - onEdit, -}: { - quote: QuoteResult; - busy: boolean; - downloading: boolean; - onAccept: () => void; - onDownload: () => void; - onEdit: () => void; -}) { - const { t } = useTranslation(); - return ( - - - {t("portal.procurement.milestone.eyebrow", { - number: quote.quoteNumber, - })} - -

- {t("portal.procurement.milestone.title")} -

- {quote.config.businessName && ( -

- {t("portal.procurement.milestone.preparedFor", { - company: quote.config.businessName, - })} -

- )} -

- {t("portal.procurement.milestone.description")} -

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} - -
  • - ))} -
-
- - {money(quote.annualNetMinor, quote.currency)} - {t("portal.procurement.milestone.perYear")} - - - {t("portal.procurement.milestone.tcv", { - value: money(quote.tcvMinor, quote.currency), - })} - -
-
- - - -
-
- ); -} - -/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */ +/** The subscription-created step: pay or download the first invoice. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, - busy, - onSimulate, }: { invoiceUrl?: string | null; invoicePdf?: string | null; - busy: boolean; - onSimulate: () => void; }) { const { t } = useTranslation(); return ( @@ -133,11 +47,6 @@ export function PaymentStageCard({ )}
)} -
- -
); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index 1ba6016a4a..b392247563 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -15,12 +15,19 @@ import { import "@portal/views/Procurement.css"; const STEPS = ["volume", "plan", "details"] as const; -const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years -const SLA_UPLIFT: Record = { - standard: 0, - priority: 0.15, - dedicated: 0.3, -}; +const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71) +// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve. +const POSTURES = [ + { intensity: 2, key: "essentials" }, + { intensity: 4, key: "governed" }, + { intensity: 7, key: "regulated" }, +] as const; +// PDF-size tiers (D93): a multiplier on the rate. Default Standard (×1.4). Mirrors the server. +const SIZE_TIERS = [ + { mult: 1.0, key: "compact" }, + { mult: 1.4, key: "standard" }, + { mult: 2.4, key: "heavy" }, +] as const; /** * The enterprise quote builder — volume → commitment & service → details. A client-side preview @@ -30,10 +37,13 @@ const SLA_UPLIFT: Record = { */ export function QuoteBuilder({ deployment, + seats = 0, initial, onGenerate, }: { deployment: string; + /** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */ + seats?: number; /** Seed the builder from an existing quote's config (re-editing a quote). */ initial?: QuoteConfigInput; /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ @@ -43,17 +53,28 @@ export function QuoteBuilder({ const [step, setStep] = useState(0); const [cfg, setCfg] = useState( initial ?? { - volume: 1_000_000, - users: 0, + // Users-first: with no seats from the trial, leave volume empty so entering the team size + // auto-fills it (rather than pre-seeding a figure that hides the users → volume estimate). + volume: seats > 0 ? estimateVolume(seats) : 0, + users: Math.max(0, seats), + intensity: 4, // Governed — the default governance posture per the pricing alignment + sizeMult: 1.4, // Standard — the default PDF-size tier (D93) deployment, termYears: 3, serviceLevel: "priority", indemnification: false, training: false, qbr: false, - offlineLicense: false, - currency: "USD", businessName: "", + contactName: "", + contactEmail: "", + addressLine1: "", + addressLine2: "", + city: "", + region: "", + postalCode: "", + poNumber: "", + taxId: "", }, ); // A seeded quote carries a volume but no user count, so treat it as manually set. @@ -159,6 +180,36 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s2Title")} sub={t("portal.procurement.builder.s2Sub")} > + +
+ {POSTURES.map((p) => ( + set("intensity", p.intensity)} + /> + ))} +
+
+ + +
+ {SIZE_TIERS.map((s) => ( + set("sizeMult", s.mult)} + /> + ))} +
+
+
{[1, 2, 3, 4, 5].map((y) => ( @@ -224,12 +275,6 @@ export function QuoteBuilder({ sub={t("portal.procurement.builder.qbrSub")} onClick={() => set("qbr", !cfg.qbr)} /> - set("offlineLicense", !cfg.offlineLicense)} - />
@@ -241,31 +286,97 @@ export function QuoteBuilder({ title={t("portal.procurement.builder.s3Title")} sub={t("portal.procurement.builder.s3Sub")} > - +
+ + set("businessName", e.target.value)} + /> + + + set("contactName", e.target.value)} + /> + +
+ + set("contactEmail", e.target.value)} + /> + + set("businessName", e.target.value)} + value={cfg.addressLine1 ?? ""} + onChange={(e) => set("addressLine1", e.target.value)} + /> + + + set("addressLine2", e.target.value)} />
- - + + set("city", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("postalCode", e.target.value)} + /> + +
+
+ + set("poNumber", e.target.value)} + /> + + + set("taxId", e.target.value)} + />